diff --git a/go.mod b/go.mod index 6477df7..27b2fc1 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 // Ensure pgx is a direct dependency go.mongodb.org/mongo-driver v1.17.3 - golang.org/x/crypto v0.41.0 - golang.org/x/sync v0.16.0 + golang.org/x/crypto v0.43.0 + golang.org/x/sync v0.17.0 gorm.io/driver/mysql v1.6.0 // GORM MySQL driver gorm.io/driver/postgres v1.5.11 // Added GORM PostgreSQL driver gorm.io/driver/sqlserver v1.6.1 // GORM SQL Server driver @@ -29,6 +29,8 @@ require ( github.com/swaggo/gin-swagger v1.6.0 github.com/swaggo/swag v1.16.6 golang.org/x/time v0.14.0 + google.golang.org/grpc v1.77.0 + google.golang.org/protobuf v1.36.10 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -92,11 +94,11 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/protobuf v1.36.7 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gorm.io/gorm v1.30.0 // indirect ) diff --git a/go.sum b/go.sum index 4fc4172..b06f5ec 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -95,6 +99,8 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -241,6 +247,18 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= @@ -257,16 +275,16 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -285,8 +303,8 @@ golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 h1:6/3JGEh1C88g7m+qzzTbl3A0FtsLguXieqofVLU/JAo= +golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -294,8 +312,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -316,8 +334,8 @@ golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -348,8 +366,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -358,11 +376,17 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/config/config.go b/internal/config/config.go index 740e634..4dbeffb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( type Config struct { Server ServerConfig + GRPC GRPCConfig Databases map[string]DatabaseConfig ReadReplicas map[string][]DatabaseConfig Auth AuthConfig @@ -30,6 +31,10 @@ type Config struct { Validator *validator.Validate } +type GRPCConfig struct { + Port int + Enabled bool +} type SwaggerConfig struct { Title string Description string @@ -200,6 +205,10 @@ func LoadConfig() *Config { Port: getEnvAsInt("PORT", 8080), Mode: getEnv("GIN_MODE", "debug"), }, + GRPC: GRPCConfig{ + Port: getEnvAsInt("GRPC_PORT", 50051), + Enabled: getEnvAsBool("GRPC_ENABLED", true), + }, Databases: make(map[string]DatabaseConfig), ReadReplicas: make(map[string][]DatabaseConfig), Auth: loadAuthConfig(), @@ -237,10 +246,10 @@ func LoadConfig() *Config { Schemes: parseSchemes(getEnv("SWAGGER_SCHEMES", "http,https")), }, Security: SecurityConfig{ - TrustedOrigins: parseOrigins(getEnv("SECURITY_TRUSTED_ORIGINS", "http://localhost:3000,http://localhost:8080")), + TrustedOrigins: parseOrigins(getEnv("SECURITY_TRUSTED_ORIGINS", "http://localhost:3000,http://localhost,http://localhost:8080,http://10.10.150.207:3001,http://10.10.150.114:3000,http://10.10.150.175:3000,http://192.168.18.7:3001,http://localhost:3001")), MaxInputLength: getEnvAsInt("SECURITY_MAX_INPUT_LENGTH", 500), RateLimit: RateLimitConfig{ - RequestsPerMinute: getEnvAsInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 60), + RequestsPerMinute: getEnvAsInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 230), Redis: RedisConfig{ Host: getEnv("REDIS_HOST", "localhost"), Port: getEnvAsInt("REDIS_PORT", 6379), diff --git a/internal/grpc/pb/pasien.pb.go b/internal/grpc/pb/pasien.pb.go new file mode 100644 index 0000000..cb30510 --- /dev/null +++ b/internal/grpc/pb/pasien.pb.go @@ -0,0 +1,191 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.2 +// source: proto/pasien.proto + +package pb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetPasienByNomrRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nomr string `protobuf:"bytes,1,opt,name=nomr,proto3" json:"nomr,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetPasienByNomrRequest) Reset() { + *x = GetPasienByNomrRequest{} + mi := &file_proto_pasien_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetPasienByNomrRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetPasienByNomrRequest) ProtoMessage() {} + +func (x *GetPasienByNomrRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_pasien_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetPasienByNomrRequest.ProtoReflect.Descriptor instead. +func (*GetPasienByNomrRequest) Descriptor() ([]byte, []int) { + return file_proto_pasien_proto_rawDescGZIP(), []int{0} +} + +func (x *GetPasienByNomrRequest) GetNomr() string { + if x != nil { + return x.Nomr + } + return "" +} + +type Pasien struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nomr string `protobuf:"bytes,1,opt,name=nomr,proto3" json:"nomr,omitempty"` + Nama string `protobuf:"bytes,2,opt,name=nama,proto3" json:"nama,omitempty"` + Umur int32 `protobuf:"varint,3,opt,name=umur,proto3" json:"umur,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pasien) Reset() { + *x = Pasien{} + mi := &file_proto_pasien_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pasien) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pasien) ProtoMessage() {} + +func (x *Pasien) ProtoReflect() protoreflect.Message { + mi := &file_proto_pasien_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pasien.ProtoReflect.Descriptor instead. +func (*Pasien) Descriptor() ([]byte, []int) { + return file_proto_pasien_proto_rawDescGZIP(), []int{1} +} + +func (x *Pasien) GetNomr() string { + if x != nil { + return x.Nomr + } + return "" +} + +func (x *Pasien) GetNama() string { + if x != nil { + return x.Nama + } + return "" +} + +func (x *Pasien) GetUmur() int32 { + if x != nil { + return x.Umur + } + return 0 +} + +var File_proto_pasien_proto protoreflect.FileDescriptor + +const file_proto_pasien_proto_rawDesc = "" + + "\n" + + "\x12proto/pasien.proto\x12\x06pasien\",\n" + + "\x16GetPasienByNomrRequest\x12\x12\n" + + "\x04nomr\x18\x01 \x01(\tR\x04nomr\"D\n" + + "\x06Pasien\x12\x12\n" + + "\x04nomr\x18\x01 \x01(\tR\x04nomr\x12\x12\n" + + "\x04nama\x18\x02 \x01(\tR\x04nama\x12\x12\n" + + "\x04umur\x18\x03 \x01(\x05R\x04umur2R\n" + + "\rPasienService\x12A\n" + + "\x0fGetPasienByNomr\x12\x1e.pasien.GetPasienByNomrRequest\x1a\x0e.pasien.PasienB!Z\x1fapi-service/internal/grpc/pb;pbb\x06proto3" + +var ( + file_proto_pasien_proto_rawDescOnce sync.Once + file_proto_pasien_proto_rawDescData []byte +) + +func file_proto_pasien_proto_rawDescGZIP() []byte { + file_proto_pasien_proto_rawDescOnce.Do(func() { + file_proto_pasien_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_pasien_proto_rawDesc), len(file_proto_pasien_proto_rawDesc))) + }) + return file_proto_pasien_proto_rawDescData +} + +var file_proto_pasien_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_proto_pasien_proto_goTypes = []any{ + (*GetPasienByNomrRequest)(nil), // 0: pasien.GetPasienByNomrRequest + (*Pasien)(nil), // 1: pasien.Pasien +} +var file_proto_pasien_proto_depIdxs = []int32{ + 0, // 0: pasien.PasienService.GetPasienByNomr:input_type -> pasien.GetPasienByNomrRequest + 1, // 1: pasien.PasienService.GetPasienByNomr:output_type -> pasien.Pasien + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_proto_pasien_proto_init() } +func file_proto_pasien_proto_init() { + if File_proto_pasien_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_pasien_proto_rawDesc), len(file_proto_pasien_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_pasien_proto_goTypes, + DependencyIndexes: file_proto_pasien_proto_depIdxs, + MessageInfos: file_proto_pasien_proto_msgTypes, + }.Build() + File_proto_pasien_proto = out.File + file_proto_pasien_proto_goTypes = nil + file_proto_pasien_proto_depIdxs = nil +} diff --git a/internal/grpc/pb/pasien_grpc.pb.go b/internal/grpc/pb/pasien_grpc.pb.go new file mode 100644 index 0000000..1e5e599 --- /dev/null +++ b/internal/grpc/pb/pasien_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc v6.33.2 +// source: proto/pasien.proto + +package pb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + PasienService_GetPasienByNomr_FullMethodName = "/pasien.PasienService/GetPasienByNomr" +) + +// PasienServiceClient is the client API for PasienService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type PasienServiceClient interface { + GetPasienByNomr(ctx context.Context, in *GetPasienByNomrRequest, opts ...grpc.CallOption) (*Pasien, error) +} + +type pasienServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewPasienServiceClient(cc grpc.ClientConnInterface) PasienServiceClient { + return &pasienServiceClient{cc} +} + +func (c *pasienServiceClient) GetPasienByNomr(ctx context.Context, in *GetPasienByNomrRequest, opts ...grpc.CallOption) (*Pasien, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Pasien) + err := c.cc.Invoke(ctx, PasienService_GetPasienByNomr_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// PasienServiceServer is the server API for PasienService service. +// All implementations must embed UnimplementedPasienServiceServer +// for forward compatibility. +type PasienServiceServer interface { + GetPasienByNomr(context.Context, *GetPasienByNomrRequest) (*Pasien, error) + mustEmbedUnimplementedPasienServiceServer() +} + +// UnimplementedPasienServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPasienServiceServer struct{} + +func (UnimplementedPasienServiceServer) GetPasienByNomr(context.Context, *GetPasienByNomrRequest) (*Pasien, error) { + return nil, status.Error(codes.Unimplemented, "method GetPasienByNomr not implemented") +} +func (UnimplementedPasienServiceServer) mustEmbedUnimplementedPasienServiceServer() {} +func (UnimplementedPasienServiceServer) testEmbeddedByValue() {} + +// UnsafePasienServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PasienServiceServer will +// result in compilation errors. +type UnsafePasienServiceServer interface { + mustEmbedUnimplementedPasienServiceServer() +} + +func RegisterPasienServiceServer(s grpc.ServiceRegistrar, srv PasienServiceServer) { + // If the following call panics, it indicates UnimplementedPasienServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PasienService_ServiceDesc, srv) +} + +func _PasienService_GetPasienByNomr_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetPasienByNomrRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PasienServiceServer).GetPasienByNomr(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PasienService_GetPasienByNomr_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PasienServiceServer).GetPasienByNomr(ctx, req.(*GetPasienByNomrRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// PasienService_ServiceDesc is the grpc.ServiceDesc for PasienService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var PasienService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "pasien.PasienService", + HandlerType: (*PasienServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetPasienByNomr", + Handler: _PasienService_GetPasienByNomr_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/pasien.proto", +} diff --git a/internal/grpc/server/grpc_server.go b/internal/grpc/server/grpc_server.go new file mode 100644 index 0000000..96e295d --- /dev/null +++ b/internal/grpc/server/grpc_server.go @@ -0,0 +1,26 @@ +package server + +import ( + "fmt" + "log" + "net" + + "api-service/internal/config" + "api-service/internal/grpc/pb" + pasienHandlers "api-service/internal/handlers/pasien" + + "google.golang.org/grpc" +) + +func RunGRPCServer(cfg *config.Config, pasienHandler *pasienHandlers.PasienHandler) error { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.GRPC.Port)) + if err != nil { + return err + } + + s := grpc.NewServer() + pb.RegisterPasienServiceServer(s, NewPasienGRPCServer(pasienHandler)) + + log.Printf("gRPC server listening on :%d", cfg.GRPC.Port) + return s.Serve(lis) +} diff --git a/internal/grpc/server/pasien_server.go b/internal/grpc/server/pasien_server.go new file mode 100644 index 0000000..0c8ba49 --- /dev/null +++ b/internal/grpc/server/pasien_server.go @@ -0,0 +1,50 @@ +package server + +import ( + "context" + + "api-service/internal/grpc/pb" + pasienHandlers "api-service/internal/handlers/pasien" +) + +type PasienGRPCServer struct { + pb.UnimplementedPasienServiceServer + handler *pasienHandlers.PasienHandler +} + +func NewPasienGRPCServer(handler *pasienHandlers.PasienHandler) *PasienGRPCServer { + return &PasienGRPCServer{handler: handler} +} + +func (s *PasienGRPCServer) GetPasien(ctx context.Context, req *pb.GetPasienRequest) (*pb.GetPasienResponse, error) { + // Di sini idealnya kamu panggil service langsung. + // Contoh pseudo (sesuaikan dengan service asli kamu): + list, err := s.handler.Service.GetPasien(ctx, int(req.Page), int(req.Limit)) + if err != nil { + return nil, err + } + + out := make([]*pb.Pasien, 0, len(list)) + for _, p := range list { + out = &pb.Pasien{ + Nomr: p.Nomr, + Nama: p.Nama, + Umur: int32(p.Umur), + } + out = append(out, out) + } + + return &pb.GetPasienResponse{Data: out}, nil +} + +func (s *PasienGRPCServer) GetPasienByNomr(ctx context.Context, req *pb.GetPasienByNomrRequest) (*pb.Pasien, error) { + p, err := s.handler.Service.GetByNomr(ctx, req.Nomr) + if err != nil { + return nil, err + } + return &pb.Pasien{ + Nomr: p.Nomr, + Nama: p.Nama, + Umur: int32(p.Umur), + }, nil +} diff --git a/internal/handlers/antrean/antrean.go b/internal/handlers/antrean/antrean.go new file mode 100644 index 0000000..7ad2b5b --- /dev/null +++ b/internal/handlers/antrean/antrean.go @@ -0,0 +1,919 @@ +package antrean + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + modelsAntrean "api-service/internal/models/antrean" + queryUtils "api-service/internal/utils/query" + "api-service/internal/utils/validation" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" + "github.com/lib/pq" + "net/http" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + validate.RegisterValidation("antrean_status", validated) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +func validated(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} +func NewAntreanHandler() *AntreanHandler { + // Initialize QueryBuilder with allowed columns list for security. + queryBuilder := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL). + SetAllowedColumns([]string{ + // Columns from permission_rol table (rpr) + "rpr.id", + "rpr.create", + "rpr.read", + "rpr.update", + "rpr.disable", + "rpr.delete", + "rpr.active", + "rpr.role_keycloak", + "rpr.group_keycloak", + "rpr.fk_rol_pages_id", + + "create", "\"create\"", + "read", "\"read\"", + "update", "\"update\"", + "disable", "\"disable\"", + "delete", "\"delete\"", + "active", "fk_rol_pages_id", "role_keycloak", "group_keycloak", "id", + // Columns from pages_rol table (rpf) + "rpf.id", + "rpf.name", + + // Alias columns + "perid", + "pecreate", + "peread", + "peupdate", + "pedisable", + "pedelete", + "pgid", + "pagename", + }) + + return &AntreanHandler{ + db: db, + queryBuilder: queryBuilder, + validator: validation.NewDynamicValidator(queryBuilder), + } +} + +type AntreanHandler struct { + db database.Service + queryBuilder *queryUtils.QueryBuilder + validator *validation.DynamicValidator +} + +func (h *AntreanHandler) GetData(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + rolesStr := c.Query("roles") + groupsStr := c.Query("groups") + + if rolesStr == "" || groupsStr == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Missing required parameters: roles (comma-separated, e.g., superadmin,admin) and groups (comma-separated, e.g., STIM,OTHERGROUP)", + }) + return + } + + roles := strings.Split(rolesStr, ",") + for i := range roles { + roles[i] = strings.TrimSpace(roles[i]) + } + var filteredRoles []string + for _, role := range roles { + if role != "" { + filteredRoles = append(filteredRoles, role) + } + } + if len(filteredRoles) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid roles provided"}) + return + } + roles = filteredRoles + + groups := strings.Split(groupsStr, ",") + for i := range groups { + groups[i] = strings.TrimSpace(groups[i]) + } + var filteredGroups []string + for _, group := range groups { + if group != "" { + filteredGroups = append(filteredGroups, group) + } + } + if len(filteredGroups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid groups provided"}) + return + } + groups = filteredGroups + + query := queryUtils.DynamicQuery{ + From: "role_access.rol_permission", + Aliases: "rpr", + Fields: []queryUtils.SelectField{ + {Expression: "rpr.id", Alias: "perid"}, + {Expression: "rpr.create", Alias: "pecreate"}, + {Expression: "rpr.read", Alias: "peread"}, + {Expression: "rpr.update", Alias: "peupdate"}, + {Expression: "rpr.disable", Alias: "pedisable"}, + {Expression: "rpr.delete", Alias: "pedelete"}, + {Expression: "rpr.active", Alias: "active"}, + {Expression: "rpf.id", Alias: "pgid"}, + {Expression: "rpf.name", Alias: "pagename"}, + }, + Joins: []queryUtils.Join{ + { + Type: "LEFT", + Table: "role_access.rol_pages", + Alias: "rpf", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "rpr.fk_rol_pages_id", Operator: queryUtils.OpEqual, Value: "rpf.id"}, + }, + }, + }, + }, + Sort: []queryUtils.SortField{ + {Column: "rpf.id", Order: "ASC"}, + }, + } + var filters []queryUtils.DynamicFilter + + filters = append(filters, queryUtils.DynamicFilter{ + Column: "rpr.role_keycloak", + Operator: queryUtils.OpArrayOverlap, + Value: pq.Array(roles), + }) + + filters = append(filters, queryUtils.DynamicFilter{ + Column: "rpr.group_keycloak", + Operator: queryUtils.OpArrayOverlap, + Value: pq.Array(groups), + }) + + query.Filters = []queryUtils.FilterGroup{ + {Filters: filters, LogicOp: "AND"}, + } + fmt.Println("INI QUERY NYA :", query) + permissions, total, err := h.fetchPermissionsDynamic(ctx, dbConn, query) + if err != nil { + logger.Error("Failed to fetch permission data", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch permission: " + err.Error()}) + return + } + + if len(permissions) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "No permission found for the given roles and groups"}) + return + } + + response := modelsAntrean.PermissionGetResponse{ + Message: "Data permission berhasil diambil", + Data: permissions, + Meta: map[string]interface{}{ + "total": total, + "count": len(permissions), + }, + } + + c.JSON(http.StatusOK, response) +} + +func (h *AntreanHandler) CreatePermission(c *gin.Context) { + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error(err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get DB connection"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + var req modelsAntrean.CreatePermissionRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.Error(err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON body: " + err.Error()}) + return + } + + // Validasi input + if len(req.Roles) == 0 || len(req.Groups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Role dan Group Tidak Boleh Kosong!!"}) + return + } + + // Build InsertData dengan query builder + insertData := queryUtils.InsertData{ + Columns: []string{ + "\"create\"", + "\"read\"", + "\"update\"", + "\"disable\"", + "\"delete\"", + "active", + "fk_rol_pages_id", + "role_keycloak", + "group_keycloak", + }, + Values: []interface{}{ + req.CreatePermission, + req.ReadPermission, + req.UpdatePermission, + req.DisablePermission, + req.DeletePermission, + req.Active, + req.PagesID, + pq.Array(req.Roles), + pq.Array(req.Groups), + }, + } + + // Build insert query dengan RETURNING + sql, args, err := h.queryBuilder.BuildInsertQuery( + "role_access.rol_permission", + insertData, + "id", // returning column + ) + if err != nil { + logger.Error("Failed to build insert query", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build query: " + err.Error()}) + return + } + + logger.Info("Executing insert query", map[string]interface{}{ + "sql": sql, + "args": args, + }) + + // Execute query + var newID int + err = dbConn.QueryRowxContext(ctx, sql, args...).Scan(&newID) + if err != nil { + // Handle PostgreSQL errors + if pqErr, ok := err.(*pq.Error); ok { + switch pqErr.Code { + case "23505": // unique_violation + logger.Error(pqErr.Error()) + c.JSON(http.StatusConflict, gin.H{"error": "Permission already exists for this pagesID, roles, and groups"}) + return + case "23503": // foreign_key_violation + logger.Error(pqErr.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pagesID: foreign key does not exist"}) + return + default: + logger.Error(pqErr.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error: " + pqErr.Error()}) + return + } + } else { + logger.Error("Failed to execute insert query", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create permission: " + err.Error()}) + return + } + } + + logger.Info("Permission created successfully", map[string]interface{}{ + "id": newID, + }) + + // Build response + permission := &modelsAntrean.PermisssionAntrean{ + IDpermission: newID, + CreatePermission: req.CreatePermission, + ReadPermission: req.ReadPermission, + UpdatePermission: req.UpdatePermission, + DisablePermission: req.DisablePermission, + DeletePermission: req.DeletePermission, + PagesID: req.PagesID, + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Data Permission Berhasil", + "permission": permission, + }) +} + +func (h *AntreanHandler) fetchPermissionsDynamic(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) ([]*modelsAntrean.PermisssionAntrean, int, error) { + logger.Info("Starting fetchPermissionsDynamic", map[string]interface{}{ + "from": query.From, + }) + + var total int + var permissions []*modelsAntrean.PermisssionAntrean + + queryCtx, queryCancel := context.WithTimeout(ctx, 30*time.Second) + defer queryCancel() + + logger.Info("Executing permission query") + + err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &permissions) + if err != nil { + logger.Error("Failed to execute permission query", map[string]interface{}{ + "error": err.Error(), + "query": query, + }) + return nil, 0, fmt.Errorf("failed to execute permission query: %w", err) + } + + logger.Info("Permission query successful", map[string]interface{}{ + "recordsFetched": len(permissions), + }) + + countCtx, countCancel := context.WithTimeout(ctx, 15*time.Second) + defer countCancel() + + count, err := h.queryBuilder.ExecuteCount(countCtx, dbConn, query) + if err != nil { + logger.Warn("Failed to get exact count, using returned records", map[string]interface{}{"error": err.Error()}) + total = len(permissions) + } else { + total = int(count) + } + + logger.Info("Query execution completed", map[string]interface{}{ + "totalRecords": total, + "returnedRecords": len(permissions), + }) + + // Check if no data found + if len(permissions) == 0 { + return nil, 0, sql.ErrNoRows + } + + return permissions, total, nil +} + +func (h *AntreanHandler) UpdatePermission(c *gin.Context) { + // Get SQLX DB connection + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error(err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get DB connection"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + // Get ID from query parameter + idStr := c.Query("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing ID in query (must be positive integer)"}) + return + } + + // Bind request body + var updateReq modelsAntrean.UpdatePermission + if err := c.ShouldBindJSON(&updateReq); err != nil { + logger.Error(err.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON body: " + err.Error()}) + return + } + + // Validate PagesID if provided + if updateReq.PagesID != nil && *updateReq.PagesID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PagesID (must be positive integer)"}) + return + } + + // Filter and validate Roles + if len(updateReq.Roles) > 0 { + var filteredRoles []string + for _, role := range updateReq.Roles { + trimmed := strings.TrimSpace(role) + if trimmed != "" { + filteredRoles = append(filteredRoles, trimmed) + } + } + if len(filteredRoles) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid roles provided"}) + return + } + updateReq.Roles = filteredRoles + } + + // Filter and validate Groups + if len(updateReq.Groups) > 0 { + var filteredGroups []string + for _, group := range updateReq.Groups { + trimmed := strings.TrimSpace(group) + if trimmed != "" { + filteredGroups = append(filteredGroups, trimmed) + } + } + if len(filteredGroups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid groups provided"}) + return + } + updateReq.Groups = filteredGroups + } + + // Build UpdateData untuk query builder + var columns []string + var values []interface{} + + // Add fields yang akan di-update (hanya yang provided) + if updateReq.CreatePermission != nil { + columns = append(columns, `"create"`) + values = append(values, *updateReq.CreatePermission) + } + if updateReq.ReadPermission != nil { + columns = append(columns, `"read"`) + values = append(values, *updateReq.ReadPermission) + } + if updateReq.UpdatePermission != nil { + columns = append(columns, `"update"`) + values = append(values, *updateReq.UpdatePermission) + } + if updateReq.DisablePermission != nil { + columns = append(columns, `"disable"`) + values = append(values, *updateReq.DisablePermission) + } + if updateReq.DeletePermission != nil { + columns = append(columns, `"delete"`) + values = append(values, *updateReq.DeletePermission) + } + if updateReq.Active != nil { + columns = append(columns, "active") + values = append(values, *updateReq.Active) + } + if updateReq.PagesID != nil { + columns = append(columns, "fk_rol_pages_id") + values = append(values, *updateReq.PagesID) + } + if len(updateReq.Roles) > 0 { + columns = append(columns, "role_keycloak") + values = append(values, pq.Array(updateReq.Roles)) + } + if len(updateReq.Groups) > 0 { + columns = append(columns, "group_keycloak") + values = append(values, pq.Array(updateReq.Groups)) + } + + // Check if there's anything to update + if len(columns) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) + return + } + + updateData := queryUtils.UpdateData{ + Columns: columns, + Values: values, + } + + // Build WHERE filter untuk UPDATE + filters := []queryUtils.FilterGroup{ + { + Filters: []queryUtils.DynamicFilter{ + { + Column: "id", + Operator: queryUtils.OpEqual, + Value: id, + }, + }, + }, + } + + // Build UPDATE query dengan RETURNING + sql, args, err := h.queryBuilder.BuildUpdateQuery( + "role_access.rol_permission", + updateData, + filters, + "id", // returning column + ) + if err != nil { + logger.Error("Failed to build update query", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to build query: " + err.Error()}) + return + } + + logger.Info("Executing update query", map[string]interface{}{ + "sql": sql, + "args": args, + }) + + // Execute UPDATE query + var updatedID int + err = dbConn.QueryRowxContext(ctx, sql, args...).Scan(&updatedID) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + logger.Error("Permission not found", map[string]interface{}{"id": id}) + c.JSON(http.StatusNotFound, gin.H{"error": "Permission not found with ID: " + strconv.Itoa(id)}) + return + } + + // Handle PostgreSQL errors + if pqErr, ok := err.(*pq.Error); ok { + switch pqErr.Code { + case "23503": // foreign_key_violation + logger.Error(pqErr.Error()) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid pagesID: foreign key does not exist"}) + return + default: + logger.Error(pqErr.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error: " + pqErr.Error()}) + return + } + } + + logger.Error("Failed to execute update query", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update permission: " + err.Error()}) + return + } + + logger.Info("Permission updated successfully", map[string]interface{}{"id": updatedID}) + + // Fetch updated data menggunakan function terpisah + updatedPermission, err := h.fetchPermissionByID(ctx, dbConn, updatedID) + if err != nil { + logger.Error("Failed to fetch updated permission", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Permission updated but failed to fetch: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Permission updated successfully", + "permission": updatedPermission, + }) +} + +func (h *AntreanHandler) fetchPermissionByID(ctx context.Context, dbConn *sqlx.DB, id int) (*modelsAntrean.PermisssionAntrean, error) { + logger.Info("Fetching permission by ID", map[string]interface{}{"id": id}) + + // Build query menggunakan query builder (sama seperti GetData) + query := queryUtils.DynamicQuery{ + From: "role_access.rol_permission", + Aliases: "rpr", + Fields: []queryUtils.SelectField{ + {Expression: "rpr.id", Alias: "perid"}, + {Expression: "rpr.\"create\"", Alias: "pecreate"}, + {Expression: "rpr.\"read\"", Alias: "peread"}, + {Expression: "rpr.\"update\"", Alias: "peupdate"}, + {Expression: "rpr.\"disable\"", Alias: "pedisable"}, + {Expression: "rpr.\"delete\"", Alias: "pedelete"}, + {Expression: "rpr.active", Alias: "active"}, + {Expression: "rpr.fk_rol_pages_id", Alias: "pgid"}, + {Expression: "rpr.role_keycloak", Alias: "role_keycloak"}, + {Expression: "rpr.group_keycloak", Alias: "group_keycloak"}, + {Expression: "rpf.id", Alias: "pgid"}, + {Expression: "rpf.\"name\"", Alias: "pagename"}, + }, + Joins: []queryUtils.Join{ + { + Type: "LEFT", + Table: "role_access.rol_pages", + Alias: "rpf", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + { + Column: "rpr.fk_rol_pages_id", + Operator: queryUtils.OpEqual, + Value: "rpf.id", + }, + }, + }, + }, + }, + Filters: []queryUtils.FilterGroup{ + { + Filters: []queryUtils.DynamicFilter{ + { + Column: "rpr.id", + Operator: queryUtils.OpEqual, + Value: id, + }, + { + Column: "rpr.active", + Operator: queryUtils.OpEqual, + Value: true, + }, + }, + LogicOp: "AND", + }, + }, + Limit: 1, + } + + // Execute query + var permissions []modelsAntrean.PermisssionAntrean + queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second) + defer queryCancel() + + err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &permissions) + if err != nil { + return nil, fmt.Errorf("failed to execute fetch query: %w", err) + } + + if len(permissions) == 0 { + return nil, sql.ErrNoRows + } + + logger.Info("Permission fetched successfully", map[string]interface{}{ + "id": permissions[0].IDpermission, + }) + + return &permissions[0], nil +} + +func (h *AntreanHandler) GetDataPermission(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + rolesStr := c.Query("roles") + groupsStr := c.Query("groups") + + if rolesStr == "" || groupsStr == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Missing required parameters: roles (comma-separated, e.g., superadmin,admin) and groups (comma-separated, e.g., STIM,OTHERGROUP)", + }) + return + } + + roles := strings.Split(rolesStr, ",") + for i := range roles { + roles[i] = strings.TrimSpace(roles[i]) + } + var filteredRoles []string + for _, role := range roles { + if role != "" { + filteredRoles = append(filteredRoles, role) + } + } + if len(filteredRoles) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid roles provided"}) + return + } + + groups := strings.Split(groupsStr, ",") + for i := range groups { + groups[i] = strings.TrimSpace(groups[i]) + } + var filteredGroups []string + for _, group := range groups { + if group != "" { + filteredGroups = append(filteredGroups, group) + } + } + if len(filteredGroups) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "No valid groups provided"}) + return + } + + query := queryUtils.DynamicQuery{ + From: "role_access.rol_permission", + Aliases: "rpr", + Fields: []queryUtils.SelectField{ + {Expression: "rpr.id", Alias: "perid"}, + {Expression: "rpr.create", Alias: "pecreate"}, + {Expression: "rpr.read", Alias: "peread"}, + {Expression: "rpr.update", Alias: "peupdate"}, + {Expression: "rpr.disable", Alias: "pedisable"}, + {Expression: "rpr.delete", Alias: "pedelete"}, + {Expression: "rpr.active", Alias: "active"}, + {Expression: "rpf.id", Alias: "pgid"}, + {Expression: "rpf.name", Alias: "pagename"}, + {Expression: "rpf.level", Alias: "level"}, + {Expression: "rpf.sort", Alias: "sort"}, + {Expression: "rpf.parent", Alias: "parent"}, + }, + Joins: []queryUtils.Join{ + { + Type: "LEFT", + Table: "role_access.rol_pages", + Alias: "rpf", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "rpr.fk_rol_pages_id", Operator: queryUtils.OpEqual, Value: "rpf.id"}, + }, + }, + }, + }, + Sort: []queryUtils.SortField{ + {Column: "rpf.level", Order: "ASC"}, + {Column: "rpf.sort", Order: "ASC"}, + {Column: "rpf.id", Order: "ASC"}, + }, + } + + var filters []queryUtils.DynamicFilter + filters = append(filters, queryUtils.DynamicFilter{ + Column: "rpr.role_keycloak", + Operator: queryUtils.OpArrayOverlap, + Value: pq.Array(filteredRoles), + }) + filters = append(filters, queryUtils.DynamicFilter{ + Column: "rpr.group_keycloak", + Operator: queryUtils.OpArrayOverlap, + Value: pq.Array(filteredGroups), + }) + + query.Filters = []queryUtils.FilterGroup{ + {Filters: filters, LogicOp: "AND"}, + } + + fmt.Println("INI QUERY NYA :", query) + + permissions, total, err := h.fetchPermissionsDynamicnew(ctx, dbConn, query) + if err != nil { + logger.Error("Failed to fetch permission data", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch permission: " + err.Error()}) + return + } + + if len(permissions) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "No permission found for the given roles and groups"}) + return + } + + sortedPermissions := h.sortHierarchical(permissions) + + response := modelsAntrean.PermissionGetResponse{ + Message: "Data permission berhasil diambil", + Data: sortedPermissions, + Meta: map[string]interface{}{ + "total": total, + "count": len(sortedPermissions), + }, + } + + c.JSON(http.StatusOK, response) +} + +// FUNGSI PENTING: Manual Hierarchical Sorting +func (h *AntreanHandler) sortHierarchical(permissions []*modelsAntrean.PermisssionAntrean) []*modelsAntrean.PermisssionAntrean { + if len(permissions) == 0 { + return permissions + } + + // Grup berdasarkan parent (0/NIL = root) + parentMap := make(map[int][]*modelsAntrean.PermisssionAntrean) + rootItems := make([]*modelsAntrean.PermisssionAntrean, 0) + + for _, perm := range permissions { + parentID := 0 // default root + if perm.Parent != nil && *perm.Parent > 0 { + parentID = *perm.Parent + } + + if parentID == 0 { + rootItems = append(rootItems, perm) + } else { + parentMap[parentID] = append(parentMap[parentID], perm) + } + } + + // Sort root items berdasarkan sort/level + sort.Slice(rootItems, func(i, j int) bool { + sortI := 999 // default jika nil + sortJ := 999 + if rootItems[i].Sort != nil { + sortI = *rootItems[i].Sort + } + if rootItems[j].Sort != nil { + sortJ = *rootItems[j].Sort + } + if sortI == sortJ { + return rootItems[i].PagesID < rootItems[j].PagesID + } + return sortI < sortJ + }) + + result := make([]*modelsAntrean.PermisssionAntrean, 0) + + // Build hierarchy secara rekursif + var buildTree func(parentID int) + buildTree = func(parentID int) { + children, exists := parentMap[parentID] + if !exists { + return + } + + // Sort children berdasarkan sort + sort.Slice(children, func(i, j int) bool { + sortI := 999 + sortJ := 999 + if children[i].Sort != nil { + sortI = *children[i].Sort + } + if children[j].Sort != nil { + sortJ = *children[j].Sort + } + if sortI == sortJ { + return children[i].PagesID < children[j].PagesID + } + return sortI < sortJ + }) + + for _, child := range children { + result = append(result, child) + buildTree(child.PagesID) // rekursi ke grandchildren + } + } + + // Mulai dari root items + for _, root := range rootItems { + result = append(result, root) + buildTree(root.PagesID) + } + + return result +} + +func (h *AntreanHandler) fetchPermissionsDynamicnew(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) ([]*modelsAntrean.PermisssionAntrean, int, error) { + logger.Info("Starting fetchPermissionsDynamic", map[string]interface{}{ + "from": query.From, + }) + + var total int + var permissions []*modelsAntrean.PermisssionAntrean + + queryCtx, queryCancel := context.WithTimeout(ctx, 30*time.Second) + defer queryCancel() + + logger.Info("Executing permission query") + + err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &permissions) + if err != nil { + logger.Error("Failed to execute permission query", map[string]interface{}{ + "error": err.Error(), + "query": query, + }) + return nil, 0, fmt.Errorf("failed to execute permission query: %w", err) + } + + logger.Info("Permission query successful", map[string]interface{}{ + "recordsFetched": len(permissions), + }) + + countCtx, countCancel := context.WithTimeout(ctx, 15*time.Second) + defer countCancel() + + count, err := h.queryBuilder.ExecuteCount(countCtx, dbConn, query) + if err != nil { + logger.Warn("Failed to get exact count, using returned records", map[string]interface{}{"error": err.Error()}) + total = len(permissions) + } else { + total = int(count) + } + + logger.Info("Query execution completed", map[string]interface{}{ + "totalRecords": total, + "returnedRecords": len(permissions), + }) + + if len(permissions) == 0 { + return nil, 0, sql.ErrNoRows + } + + return permissions, total, nil +} diff --git a/internal/handlers/master/dokter.go b/internal/handlers/master/dokter.go new file mode 100644 index 0000000..fd6787c --- /dev/null +++ b/internal/handlers/master/dokter.go @@ -0,0 +1,228 @@ +package master + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + modelsMaster "api-service/internal/models/master" + "api-service/pkg/logger" + "context" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" + "net/http" + "strconv" + "strings" + "time" +) + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validateRetribusiStatus) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +func validateRetribusiStatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +type DokterHandler struct { + db database.Service +} + +func NewDokterHandler() *DokterHandler { + return &DokterHandler{ + db: db, + } +} + +func (h *DokterHandler) GetDokterbyspesialis(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + klinikIDStr := c.Param("idklinik") + klinikID, err := strconv.Atoi(klinikIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid klinik ID"}) + return + } + + dbAntrean, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Failed to connect postgres_antrean", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + spesialisIDs, err := h.GetSpesialisIDsByKlinik(ctx, dbAntrean, klinikID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + if len(spesialisIDs) == 0 { + c.JSON(http.StatusOK, modelsMaster.DokterResponse{ + Message: "Tidak ada dokter untuk klinik ini", + Data: []*modelsMaster.DokterFormatted{}, + Meta: map[string]interface{}{"total": 0}, + }) + return + } + + logger.Info("Spesialis IDs found", map[string]interface{}{ + "klinikID": klinikID, + "spesialisIDs": spesialisIDs, + "count": len(spesialisIDs), + }) + + dbConn, err := h.db.GetSQLXDB("postgres_satudata") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + dokterList, err := h.GetDokterBySpesialisIDs(ctx, dbConn, spesialisIDs) + if err != nil { + logger.Error("Failed to get dokter data", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get dokter data"}) + return + } + + formattedData := h.FormatDokterData(dokterList) + + c.JSON(http.StatusOK, modelsMaster.DokterResponse{ + Message: "Data dokter berhasil diambil", + Data: formattedData, + Meta: map[string]interface{}{ + "total": len(formattedData), + "klinikID": klinikID, + "jumlahSpesialis": len(spesialisIDs), + }, + }) +} + +func (h *DokterHandler) GetSpesialisIDsByKlinik(ctx context.Context, dbConn *sqlx.DB, klinikID int) ([]int, error) { + query := ` + SELECT fk_sd_spesialis_id + FROM master.ms_healthcare_service_spesialis + WHERE fk_ms_healthcare_service_id = $1 + ` + + var spesialisIDs []int + err := dbConn.SelectContext(ctx, &spesialisIDs, query, klinikID) + if err != nil { + return nil, fmt.Errorf("failed to get spesialis IDs: %w", err) + } + + return spesialisIDs, nil +} + +func (h *DokterHandler) FormatDokterData(data []*modelsMaster.ListDokter) []*modelsMaster.DokterFormatted { + result := make([]*modelsMaster.DokterFormatted, 0, len(data)) + + for _, dokter := range data { + namaLengkap := "" + + if dokter.GelarDepan != "" { + gelarDepanList := strings.Split(dokter.GelarDepan, "|") + var formattedGelar []string + + for _, gelar := range gelarDepanList { + gelar = strings.TrimSpace(gelar) + + if gelar != "" && !strings.HasSuffix(gelar, ".") { + gelar += "." + } + + if gelar != "" { + formattedGelar = append(formattedGelar, gelar) + } + } + + if len(formattedGelar) > 0 { + namaLengkap = strings.Join(formattedGelar, " ") + " " + } + } + + namaLengkap += strings.TrimSpace(dokter.NamaLengkap) + + if dokter.GelarBelakang != "" { + gelarBelakangList := strings.Split(dokter.GelarBelakang, "|") + var formattedGelar []string + + for _, gelar := range gelarBelakangList { + gelar = strings.TrimSpace(gelar) + if gelar != "" { + formattedGelar = append(formattedGelar, gelar) + } + } + + if len(formattedGelar) > 0 { + namaLengkap += ", " + strings.Join(formattedGelar, ", ") + } + } + + formatted := &modelsMaster.DokterFormatted{ + NamaLengkapFormatted: strings.TrimSpace(namaLengkap), + Spesialis: strings.TrimSpace(dokter.Spesialis), + Subspesialis: strings.TrimSpace(dokter.Subspesialis), + } + + result = append(result, formatted) + } + + return result +} + +func (h *DokterHandler) GetDokterBySpesialisIDs(ctx context.Context, dbConn *sqlx.DB, spesialisIDs []int) ([]*modelsMaster.ListDokter, error) { + query, args, err := sqlx.In(` + SELECT + ARRAY_TO_STRING( + ARRAY( + SELECT dgd."Gelar_depan" + FROM data_pegawai_daftar_gelar_depan dpdgd + LEFT JOIN daftar_gelar_depan dgd ON dpdgd.daftar_gelar_depan_id = dgd.id + WHERE dpdgd.data_pegawai_id = dp.id + ), '|' + ) AS gelardepan, + COALESCE(dk."Nama_lengkap", '') AS namalengkap, + ARRAY_TO_STRING( + ARRAY( + SELECT dgb."Gelar_belakang" + FROM data_pegawai_daftar_gelar_belakang dpdgb + LEFT JOIN daftar_gelar_belakang dgb ON dpdgb.daftar_gelar_belakang_id = dgb.id + WHERE dpdgb.data_pegawai_id = dp.id + ), '|' + ) AS gelarbelakang, + ds."Subspesialis", + dss."Spesialis" + FROM "data_pegawai" dp + LEFT JOIN data_ktp dk ON dp."KTP" = dk.id + LEFT JOIN daftar_subspesialis ds ON dp."Subspesialis" = ds.id + LEFT JOIN daftar_spesialis dss ON ds."FK_daftar_spesialis_ID" = dss.id + WHERE dss.id IN (?) + ORDER BY dk."Nama_lengkap" ASC + `, spesialisIDs) + + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + query = dbConn.Rebind(query) + + var dokterList []*modelsMaster.ListDokter + err = dbConn.SelectContext(ctx, &dokterList, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + + return dokterList, nil +} diff --git a/internal/handlers/master/loket.go b/internal/handlers/master/loket.go new file mode 100644 index 0000000..0f56815 --- /dev/null +++ b/internal/handlers/master/loket.go @@ -0,0 +1,403 @@ +package master + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + "api-service/internal/models/master" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" + "net/http" + "time" +) + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validateloketstatus) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +func validateloketstatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +type LoketHandler struct { + db database.Service +} + +func NewLoketGHandler() *LoketHandler { + return &LoketHandler{ + db: db, + } +} + +func (h *LoketHandler) GetLoket(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + IdLoket := c.Param("loket") + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + remainingQuota, err := h.GetRemainingQuota(ctx, dbConn, IdLoket) + if err != nil { + logger.Warn("Failed to get remaining quota, defaulting to 0", map[string]interface{}{ + "error": err.Error(), + }) + remainingQuota = 0 + } + + ticketQueue, err := h.GetTicketQueueData(ctx, dbConn, IdLoket) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, master.TicketQueueResponse{ + Message: "Tidak ada antrian ticket", + Data: []*master.LoketTiketResponse{}, + Meta: map[string]interface{}{ + "total": 0, + "counter_id": IdLoket, + }, + }) + return + } + logger.Error("Failed to get ticket queue", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve ticket queue"}) + return + } + + mappedData := h.MapToLoketTiketResponse(ticketQueue) + + c.JSON(http.StatusOK, master.TicketQueueResponse{ + Message: "Data antrian ticket berhasil diambil", + KuotaLoket: remainingQuota, + Data: mappedData, + Meta: map[string]interface{}{ + "total": len(ticketQueue), + "counter_id": IdLoket, + }, + }) +} + +func (h *LoketHandler) GetTicketQueueData(ctx context.Context, dbConn *sqlx.DB, counterID string) ([]*master.TicketQueue, error) { + query := ` + select tpvhs.id ,tpvhs.healtcare_service_code ,tpvhs.ticket ,tpvhs.datetime_start, + tpv.visit_code , mhs."name" , mhs.code ,mhss.shift_number, rpt."name" as "payment", + rvs."name" as "posisi", rvs.description as "deskripsi", tpvhsvs.datetime as "waktuposisi" + from "transaction".tr_patient_visit_healthcare_service tpvhs + left join master.ms_healthcare_service mhs on tpvhs.fk_ms_healthcare_service_id = mhs.id + left join master.ms_registration_counter mrc on tpvhs.fk_ms_registration_counter_id = mrc.id + left join "transaction".tr_patient_visit tpv on tpvhs.fk_tr_patient_visit_id = tpv.id + left join reference.ref_healthcare_type rht on tpvhs.fk_ref_healthcare_type_id = rht.id + left join reference.ref_service_type rst on tpvhs.fk_ref_service_type_id = rst.id + left join master.ms_sub_healthcare_service mshs on tpvhs.fk_ms_sub_healthcare_service_id = mshs.id + left join reference.ref_visit_type rvt on tpvhs.fk_ref_visit_type_id = rvt.id + left join reference.ref_payment_type rpt on tpvhs.fk_ref_payment_type_id = rpt.id + left join master.ms_healthcare_service_shift mhss on mhss.fk_ms_healthcare_service_id = mhs.id + left join "transaction".tr_patient_visit_healthcare_service_visit_status tpvhsvs + on tpvhsvs.fk_tr_patient_visit_healthcare_service_id =tpvhs.id + left join reference.ref_visit_status rvs on tpvhsvs.fk_ref_visit_status_id = rvs.id + where mrc.id = $1 + and tpvhs.active = true + and tpv.active = true + ORDER BY tpvhs.datetime_start ASC + ` + + rows, err := dbConn.QueryContext(ctx, query, counterID) + if err != nil { + return nil, fmt.Errorf("failed to execute query: %w", err) + } + defer rows.Close() + + var ticketQueue []*master.TicketQueue + + for rows.Next() { + var ticket master.TicketQueue + + err := rows.Scan( + &ticket.ID, + &ticket.HealthcareServiceCode, + &ticket.Ticket, + &ticket.DatetimeStart, + &ticket.VisitCode, + &ticket.HealthcareServiceName, + &ticket.HealthcareServiceCodeMS, + &ticket.ShiftNumber, + &ticket.PaymentTypeName, + &ticket.Posisi, + &ticket.Deskripsi, + &ticket.WaktuPosisi, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + ticketQueue = append(ticketQueue, &ticket) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + // Check if no data found + if len(ticketQueue) == 0 { + return nil, sql.ErrNoRows + } + + logger.Info("Ticket queue retrieved successfully", map[string]interface{}{ + "counterID": counterID, + "total": len(ticketQueue), + }) + + return ticketQueue, nil +} + +func (h *LoketHandler) MapToLoketTiketResponse(tickets []*master.TicketQueue) []*master.LoketTiketResponse { + ticketMap := make(map[int64]*master.LoketTiketResponse) + + for _, ticket := range tickets { + // Jika ticket ID belum ada di map, buat entry baru + if _, exists := ticketMap[ticket.ID]; !exists { + ticketNumber := ticket.Ticket + if ticket.HealthcareServiceCode.Valid && ticket.HealthcareServiceCode.String != "" { + ticketNumber = ticket.HealthcareServiceCode.String + ticket.Ticket + } + + klinikName := "" + if ticket.HealthcareServiceName.Valid { + klinikName = ticket.HealthcareServiceName.String + } + + shift := 0 + if ticket.ShiftNumber.Valid { + shift = int(ticket.ShiftNumber.Int64) + } + + payment := "" + if ticket.PaymentTypeName.Valid { + payment = ticket.PaymentTypeName.String + } + + ticketMap[ticket.ID] = &master.LoketTiketResponse{ + IdLoketTiket: fmt.Sprintf("%d", ticket.ID), + TiketLoket: ticketNumber, + KlinikTiketLoket: klinikName, + BarcodeTiketLoket: ticket.VisitCode, + TanggalTiketLoket: ticket.DatetimeStart.Format("2006-01-02"), + WaktuTiketLoket: ticket.DatetimeStart.Format("15:04:05"), + ShiftTiketLoket: shift, + Pembayaran: payment, + Posisi: []master.Posisi{}, + } + } + + // Tambahkan posisi ke array (jika ada data posisi) + if (ticket.Posisi.Valid && ticket.Posisi.String != "") || + (ticket.Deskripsi.Valid && ticket.Deskripsi.String != "") || + (ticket.WaktuPosisi.Valid) { + + posisiData := master.Posisi{} + + if ticket.Posisi.Valid { + posisiData.Posisi = ticket.Posisi.String + } + + if ticket.Deskripsi.Valid { + posisiData.Deskripsi = ticket.Deskripsi.String + } + + if ticket.WaktuPosisi.Valid { + posisiData.TanggalPosisi = ticket.WaktuPosisi.Time.Format("2006-01-02") + posisiData.WaktuPosisi = ticket.WaktuPosisi.Time.Format("15:04:05") + } + + ticketMap[ticket.ID].Posisi = append(ticketMap[ticket.ID].Posisi, posisiData) + } + } + + // Convert map ke slice + result := make([]*master.LoketTiketResponse, 0, len(ticketMap)) + for _, ticket := range ticketMap { + result = append(result, ticket) + } + + return result +} + +func (h *LoketHandler) GetRemainingQuota(ctx context.Context, dbConn *sqlx.DB, counterID string) (int, error) { + currentDate := time.Now().Format("2006-01-02") + + query := ` + SELECT + mrc.quota, + COALESCE(tmrcsq.seat_quota, 0) as used_quota + FROM master.ms_registration_counter mrc + LEFT JOIN temporary.tm_registration_counter_seat_quota tmrcsq + ON tmrcsq.fk_ms_registration_counter_id = mrc.id + AND tmrcsq.date = $1 + WHERE mrc.id = $2 + ` + + var maxQuota, usedQuota int + err := dbConn.QueryRowContext(ctx, query, currentDate, counterID).Scan(&maxQuota, &usedQuota) + if err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("loket not found") + } + return 0, fmt.Errorf("failed to get quota: %w", err) + } + + remainingQuota := maxQuota - usedQuota + + logger.Info("Remaining quota retrieved", map[string]interface{}{ + "counter_id": counterID, + "max_quota": maxQuota, + "used_quota": usedQuota, + "remaining_quota": remainingQuota, + }) + + return remainingQuota, nil +} + +func (h *LoketHandler) GetKlinikRuang(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + // Get data klinik dan ruangan + listKlinikRuangan, err := h.GetRuang(ctx, dbConn) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, master.KlinikRuanganResponse{ + Message: "Data klinik dan ruangan tidak ditemukan", + Data: []*master.KlinikRuanganGrouped{}, + Meta: map[string]interface{}{ + "total": 0, + }, + }) + return + } + + logger.Error("Failed to get klinik ruangan", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve data"}) + return + } + + // Group by klinik + groupedData := h.groupByKlinikRuangan(listKlinikRuangan) + + c.JSON(http.StatusOK, master.KlinikRuanganResponse{ + Message: "Data klinik dan ruangan berhasil diambil", + Data: groupedData, + Meta: map[string]interface{}{ + "total": len(groupedData), + }, + }) +} + +func (h *LoketHandler) GetRuang(ctx context.Context, dbConn *sqlx.DB) ([]*master.KlinikRuangan, error) { + query := ` + SELECT + mhs.id as "idklinik", + mhs.name as "namaklinik", + mshs.id as "idruangan", + mshs.name as "namaruangan" + FROM master.ms_healthcare_service mhs + LEFT JOIN master.ms_sub_healthcare_service mshs + ON mshs.fk_ms_healthcare_service_id = mhs.id + ORDER BY mhs.id, mshs.id + ` + + var listKlinikRuangan []*master.KlinikRuangan + + err := dbConn.SelectContext(ctx, &listKlinikRuangan, query) + if err != nil { + return nil, fmt.Errorf("failed to get klinik ruangan: %w", err) + } + + if len(listKlinikRuangan) == 0 { + return nil, sql.ErrNoRows + } + + logger.Info("Klinik ruangan retrieved successfully", map[string]interface{}{ + "total": len(listKlinikRuangan), + }) + + return listKlinikRuangan, nil +} + +func (h *LoketHandler) groupByKlinikRuangan(data []*master.KlinikRuangan) []*master.KlinikRuanganGrouped { + klinikMap := make(map[string]*master.KlinikRuanganGrouped) + ruanganMap := make(map[string]map[string]bool) + + for _, item := range data { + if !item.IDKlinik.Valid || item.IDKlinik.String == "" { + continue + } + + idKlinik := item.IDKlinik.String + + if _, exists := klinikMap[idKlinik]; !exists { + namaKlinik := "" + if item.NamaKlinik.Valid { + namaKlinik = item.NamaKlinik.String + } + + klinikMap[idKlinik] = &master.KlinikRuanganGrouped{ + IDKlinik: idKlinik, + NamaKlinik: namaKlinik, + Ruangan: []master.RuanganDetail{}, + } + ruanganMap[idKlinik] = make(map[string]bool) + } + + if item.IDRuangan.Valid && item.IDRuangan.String != "" { + idRuangan := item.IDRuangan.String + + if !ruanganMap[idKlinik][idRuangan] { + namaRuangan := "" + if item.NamaRuangan.Valid { + namaRuangan = item.NamaRuangan.String + } + + ruanganDetail := master.RuanganDetail{ + IDRuangan: idRuangan, + NamaRuangan: namaRuangan, + } + + klinikMap[idKlinik].Ruangan = append(klinikMap[idKlinik].Ruangan, ruanganDetail) + ruanganMap[idKlinik][idRuangan] = true + } + } + } + + result := make([]*master.KlinikRuanganGrouped, 0, len(klinikMap)) + for _, klinik := range klinikMap { + result = append(result, klinik) + } + + return result +} diff --git a/internal/handlers/master/poliklinik.go b/internal/handlers/master/poliklinik.go new file mode 100644 index 0000000..9868026 --- /dev/null +++ b/internal/handlers/master/poliklinik.go @@ -0,0 +1,949 @@ +package master + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + modelsMaster "api-service/internal/models/master" + queryUtils "api-service/internal/utils/query" + "api-service/internal/utils/validation" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" + "log" + "net/http" + "strings" + "sync" + "time" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + validate.RegisterValidation("satudata_status", validated) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +func validated(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +func NewPoliHandler() *PoliHandler { + // Initialize QueryBuilder with allowed columns list for security. + queryBuilder := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL). + SetAllowedColumns([]string{ + "mrc.id", + "mrc.name", + "mrc.code", + "mrc.quota", + "mrc.active", + "mrc.fk_ref_healthcare_type_id", + "mrc.fk_ref_service_type_id", + + "rht.id", + "rht.name", + + "rst.id", + "rst.name", + + "mrcvt.id", + "mrcvt.fk_ms_registration_counter_id", + "mrcvt.fk_ref_visit_type_id", + + "rvt.id", + "rvt.name", + + "mrcpt.id", + "mrcpt.fk_ms_registration_counter_id", + "mrcpt.fk_ref_payment_type_id", + + "rpt.id", + "rpt.name", + + "mrchs.id", + "mrchs.fk_ms_registration_counter_id", + "mrchs.fk_ms_healthcare_service_id", + "mhss2.fk_ms_healthcare_service_id", + "master.ms_healthcare_service_shift", + "fk_ms_registration_counter_id", + "master.ms_healthcare_service_schedule", + "mhss3.fk_ms_healthcare_service_id", + + "mhs.id", + "mhs.name", + + "mhss2", + "mhss2.quota", + "mhss2.shift", + + "namaloket", + "kodeloket", + "kuotaloket", + "loketaktif", + "jenisloket", + "tipeloket", + "tipevisit", + "pembayaran", + "namaklinik", + "idpayment", + + "id", + "name", + "code", + "quota", + "active", + "shift", + "kuota", + }) + + return &PoliHandler{ + db: db, + queryBuilder: queryBuilder, + validator: validation.NewDynamicValidator(queryBuilder), + } +} + +type PoliHandler struct { + db database.Service + queryBuilder *queryUtils.QueryBuilder + validator *validation.DynamicValidator +} + +func (h *PoliHandler) GetAnjungan(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + req := c.Param("jenis") + + var tipeKlinik string + + switch strings.ToLower(req) { + case "reguler", "REGULER": + tipeKlinik = "KLINIK REGULER" + case "vip": + tipeKlinik = "KLINIK VIP" + case "eksekutif", "EKSEKUTIF": + tipeKlinik = "KLINIK EKSEKUTIF" + default: + } + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + currentDate := time.Now().Format("2006-01-02") + + query := queryUtils.DynamicQuery{ + From: "master.ms_kiosk", + Aliases: "mk", + Fields: []queryUtils.SelectField{ + {Expression: "mk.name", Alias: "tipeanjungan"}, + {Expression: "mk.active", Alias: "active"}, + {Expression: "mhs.id", Alias: "idklinik"}, + {Expression: "mhs.name", Alias: "namaklinik"}, + {Expression: "mhs.code", Alias: "code"}, + {Expression: "mhss.ds_sd_spesialis", Alias: "spesialis"}, + {Expression: "mhss.fk_sd_spesialis_id", Alias: "idspesialis"}, + {Expression: "mks.day_of_week", Alias: "hari"}, + {Expression: "mhss3.available_start_time", Alias: "mulai"}, + {Expression: "mhss3.available_end_time", Alias: "selesai"}, + {Expression: "mks.available_start_time", Alias: "mulaianjungan"}, + {Expression: "mks.available_end_time", Alias: "selesaianjungan"}, + {Expression: "mhss2.shift_number", Alias: "shift"}, + {Expression: "mhss2.quota", Alias: "kuota"}, + {Expression: "mrc.id", Alias: "idloket"}, + {Expression: "mrc.name", Alias: "namaloket"}, + {Expression: "mrc.code", Alias: "kodeloket"}, + {Expression: "mrc.quota", Alias: "quota_loket"}, + {Expression: "(mrc.quota - COALESCE(tmrcsq.seat_quota, 0))", Alias: "available_quota"}, + {Expression: "rpt.id", Alias: "idpayment"}, + {Expression: "rpt.name", Alias: "paymenttype"}, + }, + Joins: []queryUtils.Join{ + { + Type: "LEFT", + Table: "master.ms_kiosk_healthcare_service", + Alias: "mkhs", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mk.id", Operator: queryUtils.OpEqual, Value: "mkhs.fk_ms_kiosk_id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_healthcare_service", + Alias: "mhs", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mkhs.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mhs.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_healthcare_service_spesialis", + Alias: "mhss", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mhs.id", Operator: queryUtils.OpEqual, Value: "mhss.fk_ms_healthcare_service_id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_kiosk_schedule", + Alias: "mks", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mks.fk_ms_kiosk_id", Operator: queryUtils.OpEqual, Value: "mk.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_healthcare_service_shift", + Alias: "mhss2", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mhss2.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mkhs.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_registration_counter_healthcare_service", + Alias: "mrchs", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrchs.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mhs.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_registration_counter", + Alias: "mrc", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "fk_ms_registration_counter_id", Operator: queryUtils.OpEqual, Value: "mrc.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "temporary.tm_registration_counter_seat_quota", + Alias: "tmrcsq", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "tmrcsq.fk_ms_registration_counter_id", Operator: queryUtils.OpEqual, Value: "mrc.id"}, + {Column: "tmrcsq.date", Operator: queryUtils.OpEqual, Value: currentDate}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_healthcare_service_schedule", + Alias: "mhss3", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mhss3.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mhs.id"}, + }, + }, + }, + { + Type: "INNER", + Table: "master.ms_kiosk_payment_type", + Alias: "mkpt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mkpt.fk_ms_kiosk_id", Operator: queryUtils.OpEqual, Value: "mk.id"}, + }, + }, + }, + + { + Type: "INNER", + Table: "master.ms_healthcare_service_payment_type", + Alias: "mhspt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mhspt.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mhs.id"}, + {Column: "mkpt.fk_ref_payment_type_id", Operator: queryUtils.OpEqual, Value: "mhspt.fk_ref_payment_type_id"}, + }, + }, + }, + { + Type: "INNER", + Table: "reference.ref_payment_type", + Alias: "rpt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mkpt.fk_ref_payment_type_id", Operator: queryUtils.OpEqual, Value: "rpt.id"}, + }, + }, + }, + }, + Filters: []queryUtils.FilterGroup{ + { + Filters: []queryUtils.DynamicFilter{ + { + Column: "mk.name", + Operator: queryUtils.OpEqual, + Value: tipeKlinik, + }, + }, + }, + }, + } + + log.Println(query) + + ListPoli, total, err := h.GetdataSpesialis(ctx, dbConn, query) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, modelsMaster.PoliklinikResponse{ + Message: "Data spesialis tidak ditemukan", + Data: []*modelsMaster.PoliklinikGrouped{}, + Meta: map[string]interface{}{ + "total": 0, + "count": 0, + }, + }) + return + } + + logger.Error("Failed to get spesialis data", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve data"}) + return + } + + groupedData := h.groupByKlinik(ListPoli) + + response := modelsMaster.PoliklinikResponse{ + Message: "Data Poli berhasil diambil", + Data: groupedData, + Meta: map[string]interface{}{ + "total": total, + "count": len(ListPoli), + }, + } + + c.JSON(http.StatusOK, response) +} + +func (h *PoliHandler) GetdataSpesialis(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) ([]*modelsMaster.ListPoli, int, error) { + var List []*modelsMaster.ListPoli + + queryCtx, queryCancel := context.WithTimeout(ctx, 30*time.Second) + defer queryCancel() + + logger.Info("Executing spesialis query") + + err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &List) + if err != nil { + logger.Error("Failed to execute spesialis query", map[string]interface{}{ + "error": err.Error(), + "query": query, + }) + return nil, 0, fmt.Errorf("failed to execute spesialis query: %w", err) + } + + total := len(List) + + logger.Info("Query execution completed", map[string]interface{}{ + "totalRecords": total, + "returnedRecords": len(List), + }) + + // Check if no data found + if total == 0 { + return nil, 0, sql.ErrNoRows + } + + return List, total, nil +} + +//func (h *PoliHandler) groupByKlinik(data []*modelsMaster.ListPoli) []*modelsMaster.PoliklinikGrouped { +// klinikMap := make(map[string]*modelsMaster.PoliklinikGrouped) +// spesialisMap := make(map[string]map[int]bool) +// jadwalMap := make(map[string]map[string]bool) +// loketMap := make(map[string]map[string]bool) +// paymentMap := make(map[string]map[string]bool) +// +// for _, item := range data { +// // Skip jika IDKlinik tidak valid atau kosong +// if !item.IDKlinik.Valid || item.IDKlinik.String == "" { +// continue +// } +// +// idKlinik := item.IDKlinik.String +// +// // Jika klinik belum ada di map, buat entry baru +// if _, exists := klinikMap[idKlinik]; !exists { +// namaKlinik := "" +// if item.NamaKlinik.Valid { +// namaKlinik = item.NamaKlinik.String +// } +// +// klinikMap[idKlinik] = &modelsMaster.PoliklinikGrouped{ +// TipeAnjungan: item.TipeAnjungan, +// Active: item.Active, +// IDKlinik: idKlinik, +// NamaKlinik: namaKlinik, +// Shift: item.Shift, +// Kuota: item.Kuota, +// Code: item.Code, +// Jadwal: []modelsMaster.ScheduleDetail{}, +// Spesialis: []modelsMaster.SpesialisDetail{}, +// Loket: []modelsMaster.LoketDetail{}, +// Payment: []modelsMaster.PaymentDetail{}, +// } +// +// // Inisialisasi map untuk tracking +// spesialisMap[idKlinik] = make(map[int]bool) +// jadwalMap[idKlinik] = make(map[string]bool) +// loketMap[idKlinik] = make(map[string]bool) +// paymentMap[idKlinik] = make(map[string]bool) +// } +// +// // Tambahkan jadwal jika belum ada +// if item.Hari != "" && item.JamMulai != "" && item.JamSelesai != "" { +// jamOperasional := fmt.Sprintf("%s - %s", item.JamMulai, item.JamSelesai) +// jadwalKey := fmt.Sprintf("%s_%s", item.Hari, jamOperasional) +// +// // Cek apakah jadwal dengan hari dan jam operasional ini sudah ada +// if !jadwalMap[idKlinik][jadwalKey] { +// schedule := modelsMaster.ScheduleDetail{ +// Hari: item.Hari, +// JamOperasional: jamOperasional, +// } +// +// klinikMap[idKlinik].Jadwal = append(klinikMap[idKlinik].Jadwal, schedule) +// jadwalMap[idKlinik][jadwalKey] = true +// } +// } +// +// // Tambahkan spesialis jika belum ada +// if item.IDSpesialis.Valid && item.IDSpesialis.Int64 != 0 && item.Spesialis.Valid && item.Spesialis.String != "" { +// idSpesialis := int(item.IDSpesialis.Int64) +// +// if !spesialisMap[idKlinik][idSpesialis] { +// spesialisDetail := modelsMaster.SpesialisDetail{ +// IDSpesialis: idSpesialis, +// Spesialis: item.Spesialis.String, +// } +// +// klinikMap[idKlinik].Spesialis = append(klinikMap[idKlinik].Spesialis, spesialisDetail) +// spesialisMap[idKlinik][idSpesialis] = true +// } +// } +// +// if item.IDloket.Valid && item.IDloket.String != "" { +// idLoket := item.IDloket.String +// +// if !loketMap[idKlinik][idLoket] { +// namaLoket := "" +// if item.Namaloket.Valid { +// namaLoket = item.Namaloket.String +// } +// +// kodeLoket := "" +// if item.KodeLoket.Valid { +// kodeLoket = item.KodeLoket.String +// } +// +// loketDetail := modelsMaster.LoketDetail{ +// IDLoket: idLoket, +// NamaLoket: namaLoket, +// KodeLoket: kodeLoket, +// } +// +// klinikMap[idKlinik].Loket = append(klinikMap[idKlinik].Loket, loketDetail) +// loketMap[idKlinik][idLoket] = true +// } +// } +// if item.PaymentType.Valid && item.PaymentType.String != "" { +// idPayment := int(item.IdPayment.Int64) +// paymentType := item.PaymentType.String +// +// if !paymentMap[idKlinik][paymentType] { +// paymentDetail := modelsMaster.PaymentDetail{ +// IDPayment: idPayment, +// PaymentType: paymentType, +// } +// +// klinikMap[idKlinik].Payment = append(klinikMap[idKlinik].Payment, paymentDetail) +// paymentMap[idKlinik][paymentType] = true +// } +// } +// } +// +// result := make([]*modelsMaster.PoliklinikGrouped, 0, len(klinikMap)) +// for _, klinik := range klinikMap { +// result = append(result, klinik) +// } +// +// return result +//} + +func (h *PoliHandler) groupByKlinik(data []*modelsMaster.ListPoli) []*modelsMaster.PoliklinikGrouped { + klinikMap := make(map[string]*modelsMaster.PoliklinikGrouped) + spesialisMap := make(map[string]map[int]bool) + jadwalMap := make(map[string]map[string]bool) + loketMap := make(map[string][]modelsMaster.LoketDetail) // ✅ Array untuk menyimpan semua loket + paymentMap := make(map[string]map[string]bool) + + for _, item := range data { + // Skip jika IDKlinik tidak valid atau kosong + if !item.IDKlinik.Valid || item.IDKlinik.String == "" { + continue + } + + idKlinik := item.IDKlinik.String + + // Jika klinik belum ada di map, buat entry baru + if _, exists := klinikMap[idKlinik]; !exists { + namaKlinik := "" + if item.NamaKlinik.Valid { + namaKlinik = item.NamaKlinik.String + } + + klinikMap[idKlinik] = &modelsMaster.PoliklinikGrouped{ + TipeAnjungan: item.TipeAnjungan, + Active: item.Active, + IDKlinik: idKlinik, + NamaKlinik: namaKlinik, + Shift: item.Shift, + Kuota: item.Kuota, + Code: item.Code, + Jadwal: []modelsMaster.ScheduleDetail{}, + Spesialis: []modelsMaster.SpesialisDetail{}, + Loket: []modelsMaster.LoketDetail{}, + Payment: []modelsMaster.PaymentDetail{}, + } + + // Inisialisasi map untuk tracking + spesialisMap[idKlinik] = make(map[int]bool) + jadwalMap[idKlinik] = make(map[string]bool) + loketMap[idKlinik] = []modelsMaster.LoketDetail{} + paymentMap[idKlinik] = make(map[string]bool) + } + + // Tambahkan jadwal jika belum ada + if item.Hari != "" && item.JamMulai != "" && item.JamSelesai != "" { + jamOperasional := fmt.Sprintf("%s - %s", item.JamMulai, item.JamSelesai) + jadwalKey := fmt.Sprintf("%s_%s", item.Hari, jamOperasional) + + if !jadwalMap[idKlinik][jadwalKey] { + schedule := modelsMaster.ScheduleDetail{ + Hari: item.Hari, + JamOperasional: jamOperasional, + } + + klinikMap[idKlinik].Jadwal = append(klinikMap[idKlinik].Jadwal, schedule) + jadwalMap[idKlinik][jadwalKey] = true + } + } + + // Tambahkan spesialis jika belum ada + if item.IDSpesialis.Valid && item.IDSpesialis.Int64 != 0 && item.Spesialis.Valid && item.Spesialis.String != "" { + idSpesialis := int(item.IDSpesialis.Int64) + + if !spesialisMap[idKlinik][idSpesialis] { + spesialisDetail := modelsMaster.SpesialisDetail{ + IDSpesialis: idSpesialis, + Spesialis: item.Spesialis.String, + } + + klinikMap[idKlinik].Spesialis = append(klinikMap[idKlinik].Spesialis, spesialisDetail) + spesialisMap[idKlinik][idSpesialis] = true + } + } + + // ✅ LOGIC BARU: Kumpulkan semua loket dulu + if item.IDloket.Valid && item.IDloket.String != "" { + idLoket := item.IDloket.String + + // Cek apakah loket ini sudah ada di array + loketExists := false + for _, loket := range loketMap[idKlinik] { + if loket.IDLoket == idLoket { + loketExists = true + break + } + } + + if !loketExists { + namaLoket := "" + if item.Namaloket.Valid { + namaLoket = item.Namaloket.String + } + + kodeLoket := "" + if item.KodeLoket.Valid { + kodeLoket = item.KodeLoket.String + } + + availableQuota := 0 + if item.AvailableQuota.Valid { + availableQuota = int(item.AvailableQuota.Int64) + } else if item.QuotaLoket.Valid { + // Kalau available_quota NULL, berarti belum ada yang pakai (awal hari) + availableQuota = int(item.QuotaLoket.Int64) + } + + loketDetail := modelsMaster.LoketDetail{ + IDLoket: idLoket, + NamaLoket: namaLoket, + KodeLoket: kodeLoket, + AvailableQuota: availableQuota, + } + + loketMap[idKlinik] = append(loketMap[idKlinik], loketDetail) + } + } + + // Payment (tetap sama) + if item.PaymentType.Valid && item.PaymentType.String != "" { + idPayment := int(item.IdPayment.Int64) + paymentType := item.PaymentType.String + + if !paymentMap[idKlinik][paymentType] { + paymentDetail := modelsMaster.PaymentDetail{ + IDPayment: idPayment, + PaymentType: paymentType, + } + + klinikMap[idKlinik].Payment = append(klinikMap[idKlinik].Payment, paymentDetail) + paymentMap[idKlinik][paymentType] = true + } + } + } + + // ✅ LOGIC PEMILIHAN LOKET + for idKlinik, loketList := range loketMap { + if len(loketList) == 0 { + continue + } + + // Cek apakah ada loket yang sudah terpakai (ada record quota hari ini) + adaYangTerpakai := false + for _, loket := range loketList { + // Kalau available_quota < quota_loket, berarti sudah ada yang pakai + for _, item := range data { + if item.IDKlinik.Valid && item.IDKlinik.String == idKlinik && + item.IDloket.Valid && item.IDloket.String == loket.IDLoket { + if item.AvailableQuota.Valid && item.QuotaLoket.Valid { + if item.AvailableQuota.Int64 < item.QuotaLoket.Int64 { + adaYangTerpakai = true + break + } + } + } + } + if adaYangTerpakai { + break + } + } + + if adaYangTerpakai { + // ✅ Sudah ada yang pakai tiket: Pilih 1 loket dengan quota terbanyak + var selectedLoket *modelsMaster.LoketDetail + maxQuota := -1 + + for i := range loketList { + if loketList[i].AvailableQuota > maxQuota { + maxQuota = loketList[i].AvailableQuota + selectedLoket = &loketList[i] + } + } + + if selectedLoket != nil { + klinikMap[idKlinik].Loket = []modelsMaster.LoketDetail{*selectedLoket} + } + } else { + // ✅ Awal hari (belum ada yang pakai): Tampilkan semua loket + klinikMap[idKlinik].Loket = loketList + } + } + + result := make([]*modelsMaster.PoliklinikGrouped, 0, len(klinikMap)) + for _, klinik := range klinikMap { + result = append(result, klinik) + } + + return result +} + +func (h *PoliHandler) GetLoket(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + query := queryUtils.DynamicQuery{ + From: "master.ms_registration_counter", + Aliases: "mrc", + Fields: []queryUtils.SelectField{ + {Expression: "mrc.id", Alias: "idloket"}, + {Expression: "mrc.name", Alias: "namaloket"}, + {Expression: "mrc.code", Alias: "kodeloket"}, + {Expression: "mrc.quota", Alias: "kuotaloket"}, + {Expression: "mrc.active", Alias: "loketaktif"}, + {Expression: "rht.name", Alias: "jenisloket"}, + {Expression: "rst.name", Alias: "tipeloket"}, + {Expression: "rvt.name", Alias: "tipevisit"}, + {Expression: "rpt.name", Alias: "pembayaran"}, + {Expression: "mhs.name", Alias: "namaklinik"}, + {Expression: "mhs.id", Alias: "idklinik"}, + }, + Joins: []queryUtils.Join{ + { + Type: "LEFT", + Table: "reference.ref_healthcare_type", + Alias: "rht", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrc.fk_ref_service_type_id", Operator: queryUtils.OpEqual, Value: "rht.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "reference.ref_service_type", + Alias: "rst", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrc.fk_ref_service_type_id", Operator: queryUtils.OpEqual, Value: "rst.id"}, + }, + }, + }, + { + Type: "INNER", + Table: "master.ms_registration_counter_visit_type", + Alias: "mrcvt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrcvt.fk_ms_registration_counter_id", Operator: queryUtils.OpEqual, Value: "mrc.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "reference.ref_visit_type", + Alias: "rvt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrcvt.fk_ref_visit_type_id", Operator: queryUtils.OpEqual, Value: "rvt.id"}, + }, + }, + }, + { + Type: "INNER", + Table: "master.ms_registration_counter_payment_type", + Alias: "mrcpt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrcpt.fk_ms_registration_counter_id", Operator: queryUtils.OpEqual, Value: "mrc.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "reference.ref_payment_type", + Alias: "rpt", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrcpt.fk_ref_payment_type_id", Operator: queryUtils.OpEqual, Value: "rpt.id"}, + }, + }, + }, + { + Type: "INNER", + Table: "master.ms_registration_counter_healthcare_service", + Alias: "mrchs", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrchs.fk_ms_registration_counter_id", Operator: queryUtils.OpEqual, Value: "mrc.id"}, + }, + }, + }, + { + Type: "LEFT", + Table: "master.ms_healthcare_service", + Alias: "mhs", + OnConditions: queryUtils.FilterGroup{ + Filters: []queryUtils.DynamicFilter{ + {Column: "mrchs.fk_ms_healthcare_service_id", Operator: queryUtils.OpEqual, Value: "mhs.id"}, + }, + }, + }, + }, + } + + log.Println(query) + + ListLoket, total, err := h.GetdataLoket(ctx, dbConn, query) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, modelsMaster.LoketResponse{ + Message: "Data loket tidak ditemukan", + Data: []*modelsMaster.LoketGrouped{}, + Meta: map[string]interface{}{ + "total": 0, + "count": 0, + }, + }) + return + } + + logger.Error("Failed to get loket data", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve data"}) + return + } + + // GROUPING DATA DISINI + groupedData := h.groupByLoket(ListLoket) + + response := modelsMaster.LoketResponse{ + Message: "Data Loket berhasil diambil", + Data: groupedData, + Meta: map[string]interface{}{ + "total_loket": len(groupedData), + "total_records": total, + }, + } + + c.JSON(http.StatusOK, response) +} + +func (h *PoliHandler) GetdataLoket(ctx context.Context, dbConn *sqlx.DB, query queryUtils.DynamicQuery) ([]*modelsMaster.ListLoket, int, error) { + var List []*modelsMaster.ListLoket + + queryCtx, queryCancel := context.WithTimeout(ctx, 30*time.Second) + defer queryCancel() + + logger.Info("Executing loket query") + + err := h.queryBuilder.ExecuteQuery(queryCtx, dbConn, query, &List) + if err != nil { + logger.Error("Failed to execute loket query", map[string]interface{}{ + "error": err.Error(), + "query": query, + }) + return nil, 0, fmt.Errorf("failed to execute loket query: %w", err) + } + + total := len(List) + + logger.Info("Query execution completed", map[string]interface{}{ + "totalRecords": total, + "returnedRecords": len(List), + }) + + if total == 0 { + return nil, 0, sql.ErrNoRows + } + + return List, total, nil +} + +// FUNGSI UNTUK GROUPING - Group by Loket +func (h *PoliHandler) groupByLoket(data []*modelsMaster.ListLoket) []*modelsMaster.LoketGrouped { + loketMap := make(map[string]*modelsMaster.LoketGrouped) + visitMap := make(map[string]map[string]bool) + klinikMap := make(map[string]map[string]bool) // Ubah jadi tracking by IDKlinik + pembayaranMap := make(map[string]map[string]bool) + + for _, item := range data { + if item.KodeLoket == "" { + continue + } + + // Jika loket belum ada di map, buat entry baru + if _, exists := loketMap[item.KodeLoket]; !exists { + loketMap[item.KodeLoket] = &modelsMaster.LoketGrouped{ + IdLoket: item.IdLoket, + TipeAnjungan: item.NamaLoket, + KodeLoket: item.KodeLoket, + KuotaLoket: item.KuotaLoket, + LoketAktif: item.LoketAktif, + JenisLoket: item.JenisLoket, + TipeLoket: item.TipeLoket, + TipeVisit: []modelsMaster.ListVisit{}, + NamaKlinik: []modelsMaster.ListKlinik{}, + NamaPembayaran: []modelsMaster.ListPembayaran{}, + } + + // Inisialisasi map untuk tracking + visitMap[item.KodeLoket] = make(map[string]bool) + klinikMap[item.KodeLoket] = make(map[string]bool) + pembayaranMap[item.KodeLoket] = make(map[string]bool) + } + + if item.TipeVisit != "" { + if !visitMap[item.KodeLoket][item.TipeVisit] { + visit := modelsMaster.ListVisit{ + TipeVisit: item.TipeVisit, + } + + loketMap[item.KodeLoket].TipeVisit = append(loketMap[item.KodeLoket].TipeVisit, visit) + visitMap[item.KodeLoket][item.TipeVisit] = true + } + } + + if item.IDKlinik != "" && item.NamaKlinik != "" { + if !klinikMap[item.KodeLoket][item.IDKlinik] { + klinik := modelsMaster.ListKlinik{ + IDKlinik: item.IDKlinik, + NamaKlinik: item.NamaKlinik, + } + + loketMap[item.KodeLoket].NamaKlinik = append(loketMap[item.KodeLoket].NamaKlinik, klinik) + klinikMap[item.KodeLoket][item.IDKlinik] = true + } + } + + if item.Pembayaran != "" { + if !pembayaranMap[item.KodeLoket][item.Pembayaran] { + pembayaran := modelsMaster.ListPembayaran{ + NamaPembayaran: item.Pembayaran, + } + + loketMap[item.KodeLoket].NamaPembayaran = append(loketMap[item.KodeLoket].NamaPembayaran, pembayaran) + pembayaranMap[item.KodeLoket][item.Pembayaran] = true + } + } + } + + // Convert map ke slice + result := make([]*modelsMaster.LoketGrouped, 0, len(loketMap)) + for _, loket := range loketMap { + result = append(result, loket) + } + + return result +} diff --git a/internal/handlers/pasien/pasien.go b/internal/handlers/pasien/pasien.go index 22bfc75..747d40b 100644 --- a/internal/handlers/pasien/pasien.go +++ b/internal/handlers/pasien/pasien.go @@ -2147,3 +2147,275 @@ func (h *PasienHandler) calculateMeta(limit, offset, total int) models.MetaRespo CurrentPage: currentPage, HasNext: offset+limit < total, HasPrev: offset > 0, } } + +func (h *PasienHandler) GetPasienByNOMR(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + Nomr := c.Param("nomr") + dbConn, err := h.db.GetSQLXDB("postgres_simrs") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + Pasien, err := h.Getpasien(ctx, dbConn, Nomr) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Pasien tidak ditemukan", + "nomr": Nomr, + }) + return + } + + logger.Error("Failed to get pasien", map[string]interface{}{ + "error": err.Error(), + "nomr": Nomr, + }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve pasien data"}) + return + } + + logger.Info("Pasien data retrieved successfully", map[string]interface{}{ + "nomr": Nomr, + "nama": Pasien.NamaLengkap, + }) + + c.JSON(http.StatusOK, gin.H{ + "message": "Data pasien berhasil diambil", + "data": Pasien, + }) + +} + +func (h *PasienHandler) Getpasien(ctx context.Context, dbConn *sqlx.DB, nomr string) (*pasienModels.PasienData, error) { + query := ` + SELECT + mp.nomr, + mp.nama, + mp.notelp, + mp.jeniskelamin, + mp.tgllahir, + mp.alamat, + COALESCE(mp2.idprovinsi, 0) as idprovinsi, + COALESCE(mp2.namaprovinsi, '') as namaprovinsi, + COALESCE(mk2.idkota, 0) as idkota, + COALESCE(mk2.namakota, '') as namakota, + COALESCE(mk.idkecamatan, 0) as idkecamatan, + COALESCE(mk.namakecamatan, '') as namakecamatan, + COALESCE(mk3.idkelurahan, 0) as idkelurahan, + COALESCE(mk3.namakelurahan, '') as namakelurahan + FROM m_pasien mp + LEFT JOIN m_provinsi mp2 ON mp.kdprovinsi = mp2.idprovinsi + LEFT JOIN m_kecamatan mk ON mp.kdkecamatan = mk.idkecamatan + LEFT JOIN m_kota mk2 ON mk.idkota = mk2.idkota + LEFT JOIN m_kelurahan mk3 ON mp.kelurahan = mk3.idkelurahan + WHERE mp.nomr = $1 + ` + + var pasien pasienModels.PasienData + + err := dbConn.QueryRowContext(ctx, query, nomr).Scan( + &pasien.Nomr, + &pasien.NamaLengkap, + &pasien.NoTelp, + &pasien.JenisKelamin, + &pasien.TTL, + &pasien.Alamat, + &pasien.IdProvinsi, + &pasien.NamaProvinsi, + &pasien.IdKota, + &pasien.NamaKota, + &pasien.IdKecamatan, + &pasien.NamaKecamatan, + &pasien.IdKelurahan, + &pasien.NamaKelurahan, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, fmt.Errorf("failed to query pasien: %w", err) + } + + logger.Info("Pasien query executed successfully", map[string]interface{}{ + "nomr": nomr, + "nama": pasien.NamaLengkap, + }) + + return &pasien, nil +} + +func (h *PasienHandler) SearchPasienByNama(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Get query parameter + nama := c.Query("nama") + + // Validasi minimal 2 karakter untuk search + if len(nama) < 2 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Minimal 2 karakter untuk pencarian", + "message": "Silahkan ketik minimal 2 huruf", + }) + return + } + + dbConn, err := h.db.GetSQLXDB("postgres_simrs") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + // Search pasien by nama + pasienList, err := h.GetPasienByNamaPattern(ctx, dbConn, nama) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, gin.H{ + "message": "Tidak ada pasien ditemukan", + "data": []pasienModels.PasienData{}, + "meta": map[string]interface{}{ + "total": 0, + "search_query": nama, + }, + }) + return + } + + logger.Error("Failed to search pasien", map[string]interface{}{ + "error": err.Error(), + "nama": nama, + }) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search pasien"}) + return + } + + logger.Info("Pasien search completed", map[string]interface{}{ + "search_query": nama, + "total_found": len(pasienList), + }) + + c.JSON(http.StatusOK, gin.H{ + "message": "Data pasien berhasil ditemukan", + "data": pasienList, + "meta": map[string]interface{}{ + "total": len(pasienList), + "search_query": nama, + }, + }) +} + +func (h *PasienHandler) GetPasienByNamaPattern(ctx context.Context, dbConn *sqlx.DB, namaPattern string) ([]*pasienModels.PasienData, error) { + searchPattern := "%" + namaPattern + "%" + + query := ` + SELECT + mp.nomr, + mp.nama, + mp.notelp, + mp.jeniskelamin, + mp.tgllahir, + mp.alamat, + mp2.idprovinsi, + mp2.namaprovinsi, + mk2.idkota, + mk2.namakota, + mk.idkecamatan, + mk.namakecamatan, + mk3.idkelurahan, + mk3.namakelurahan + FROM m_pasien mp + LEFT JOIN m_provinsi mp2 ON mp.kdprovinsi = mp2.idprovinsi + LEFT JOIN m_kecamatan mk ON mp.kdkecamatan = mk.idkecamatan + LEFT JOIN m_kota mk2 ON mk.idkota = mk2.idkota + LEFT JOIN m_kelurahan mk3 ON mp.kelurahan = mk3.idkelurahan + WHERE mp.nama ILIKE $1 + ORDER BY mp.nama ASC + LIMIT 1 + ` + + rows, err := dbConn.QueryContext(ctx, query, searchPattern) + if err != nil { + return nil, fmt.Errorf("failed to execute search query: %w", err) + } + defer rows.Close() + + var pasienList []*pasienModels.PasienData + + for rows.Next() { + var pasien pasienModels.PasienData + + err := rows.Scan( + &pasien.Nomr, + &pasien.NamaLengkap, + &pasien.NoTelp, + &pasien.JenisKelamin, + &pasien.TTL, + &pasien.Alamat, + &pasien.IdProvinsi, + &pasien.NamaProvinsi, + &pasien.IdKota, + &pasien.NamaKota, + &pasien.IdKecamatan, + &pasien.NamaKecamatan, + &pasien.IdKelurahan, + &pasien.NamaKelurahan, + ) + + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + + // Clean strings + pasien.Nomr = cleanString(pasien.Nomr) + pasien.NamaLengkap = cleanString(pasien.NamaLengkap) + pasien.NoTelp = cleanString(pasien.NoTelp) + pasien.JenisKelamin = cleanString(pasien.JenisKelamin) + pasien.Alamat = cleanString(pasien.Alamat) + pasien.NamaProvinsi = cleanString(pasien.NamaProvinsi) + pasien.NamaKota = cleanString(pasien.NamaKota) + pasien.NamaKecamatan = cleanString(pasien.NamaKecamatan) + pasien.NamaKelurahan = cleanString(pasien.NamaKelurahan) + + pasienList = append(pasienList, &pasien) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("rows iteration error: %w", err) + } + + if len(pasienList) == 0 { + return nil, sql.ErrNoRows + } + + logger.Info("Search query executed successfully", map[string]interface{}{ + "pattern": namaPattern, + "found": len(pasienList), + }) + + return pasienList, nil +} + +func cleanString(s string) string { + // Trim leading dan trailing whitespace + s = strings.TrimSpace(s) + + // Replace newline, carriage return, dan tab di tengah string dengan single space + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\t", " ") + + // Replace multiple spaces dengan single space + s = strings.Join(strings.Fields(s), " ") + + return s +} diff --git a/internal/handlers/reference/reference.go b/internal/handlers/reference/reference.go new file mode 100644 index 0000000..bb24aec --- /dev/null +++ b/internal/handlers/reference/reference.go @@ -0,0 +1,122 @@ +package reference + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + modelsReference "api-service/internal/models/reference" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/jmoiron/sqlx" + "net/http" + "sync" + "time" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validateRetribusiStatus) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +func validateRetribusiStatus(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +type referenceHandler struct { + db database.Service +} + +func NewreferenceHandler() *referenceHandler { + return &referenceHandler{ + db: db, + } +} + +func (h *referenceHandler) Getvisitpasien(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 120*time.Second) + defer cancel() + + dbAntrean, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Failed to connect postgres_antrean", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + Listvisit, err := h.Getrefvisitpasien(ctx, dbAntrean) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusOK, modelsReference.VisitStatusResponse{ + Message: "Data visit status tidak ditemukan", + Data: []*modelsReference.VisitStatus{}, + Meta: map[string]interface{}{ + "total": 0, + }, + }) + return + } + + logger.Error("Failed to get visit status", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve data"}) + return + } + + c.JSON(http.StatusOK, modelsReference.VisitStatusResponse{ + Message: "Data visit status berhasil diambil", + Data: Listvisit, + Meta: map[string]interface{}{ + "total": len(Listvisit), + }, + }) +} + +func (h *referenceHandler) Getrefvisitpasien(ctx context.Context, dbConn *sqlx.DB) ([]*modelsReference.VisitStatus, error) { + query := ` + SELECT + rvs.id as idvisit, + rvs.name as namastatus, + rvs.description as deskripsistatus, + rhs.id as idklinikstatus, + rhs.name as namastatusklinik + FROM reference.ref_visit_status rvs + LEFT JOIN reference.ref_healthcare_status rhs + ON rvs.fk_ref_healthcare_status_id = rhs.id + ORDER BY rvs.id ASC + ` + + var visitStatusList []*modelsReference.VisitStatus + + err := dbConn.SelectContext(ctx, &visitStatusList, query) + if err != nil { + return nil, fmt.Errorf("failed to get visit status: %w", err) + } + + if len(visitStatusList) == 0 { + return nil, sql.ErrNoRows + } + + logger.Info("Visit status retrieved successfully", map[string]interface{}{ + "total": len(visitStatusList), + }) + + return visitStatusList, nil +} diff --git a/internal/handlers/retribusi/retribusi.go b/internal/handlers/retribusi/retribusi.go index db5fb1e..a489105 100644 --- a/internal/handlers/retribusi/retribusi.go +++ b/internal/handlers/retribusi/retribusi.go @@ -34,7 +34,6 @@ var ( validate *validator.Validate ) -// Initialize the database connection and validator once func init() { once.Do(func() { db = database.New(config.LoadConfig()) diff --git a/internal/handlers/transaction/quota.go b/internal/handlers/transaction/quota.go new file mode 100644 index 0000000..10d2307 --- /dev/null +++ b/internal/handlers/transaction/quota.go @@ -0,0 +1,143 @@ +package transaction + +import ( + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/jmoiron/sqlx" +) + +func (h *TiketHandler) CheckAndLockQuota(ctx context.Context, dbConn *sqlx.DB, loketID int, date string) (quotaSeat int, usedQuota int, err error) { + query := ` + SELECT mrc.quota, + COALESCE(tmrcsq.seat_quota, 0) as used_quota + FROM master.ms_registration_counter mrc + LEFT JOIN temporary.tm_registration_counter_seat_quota tmrcsq + ON tmrcsq.fk_ms_registration_counter_id = mrc.id + AND tmrcsq.date = $1 + WHERE mrc.id = $2 + FOR UPDATE OF mrc + ` + + err = dbConn.QueryRowContext(ctx, query, date, loketID).Scan("aSeat, &usedQuota) + return quotaSeat, usedQuota, err +} + +func (h *TiketHandler) IncrementUsedQuota(ctx context.Context, dbConn *sqlx.DB, loketID int, date string, newUUID string) (newUsedQuota int, err error) { + checkQuery := ` + SELECT seat_quota + FROM temporary.tm_registration_counter_seat_quota + WHERE fk_ms_registration_counter_id = $1 + AND date = $2 + ` + + var currentQuota int + err = dbConn.QueryRowContext(ctx, checkQuery, loketID, date).Scan(¤tQuota) + + if err == sql.ErrNoRows { + + insertQuery := ` + INSERT INTO temporary.tm_registration_counter_seat_quota + (id, fk_ms_registration_counter_id, date, seat_quota) + VALUES ($1, $2, $3, 1) + RETURNING seat_quota + ` + err = dbConn.QueryRowContext(ctx, insertQuery, newUUID, loketID, date).Scan(&newUsedQuota) + if err != nil { + return 0, fmt.Errorf("failed to insert quota: %w", err) + } + return newUsedQuota, nil + + } else if err != nil { + // Error lain + return 0, fmt.Errorf("failed to check existing quota: %w", err) + } + + updateQuery := ` + UPDATE temporary.tm_registration_counter_seat_quota + SET seat_quota = seat_quota + 1 + WHERE fk_ms_registration_counter_id = $1 + AND date = $2 + RETURNING seat_quota + ` + err = dbConn.QueryRowContext(ctx, updateQuery, loketID, date).Scan(&newUsedQuota) + if err != nil { + return 0, fmt.Errorf("failed to update quota: %w", err) + } + + return newUsedQuota, nil +} + +func (h *TiketHandler) MinQuota(ctx context.Context, dbConn *sqlx.DB, loketID int, date string) (newUsedQuota int, err error) { + // Step 1: Cek quota saat ini + checkQuery := ` + SELECT seat_quota + FROM temporary.tm_registration_counter_seat_quota + WHERE fk_ms_registration_counter_id = $1 + AND date = $2 + ` + + var currentQuota int + err = dbConn.QueryRowContext(ctx, checkQuery, loketID, date).Scan(¤tQuota) + + if err == sql.ErrNoRows { + // Tidak ada record, berarti quota belum pernah dipakai + logger.Warn("No quota record found, nothing to decrement", map[string]interface{}{ + "loket_id": loketID, + "date": date, + }) + return 0, fmt.Errorf("no quota record found for this loket and date") + } else if err != nil { + return 0, fmt.Errorf("failed to check existing quota: %w", err) + } + + // Step 2: Validasi quota tidak boleh negatif + if currentQuota <= 0 { + logger.Warn("Quota already at zero, cannot decrement", map[string]interface{}{ + "loket_id": loketID, + "current_quota": currentQuota, + }) + return 0, fmt.Errorf("quota already at zero") + } + + // Step 3: Kurangi quota + updateQuery := ` + UPDATE temporary.tm_registration_counter_seat_quota + SET seat_quota = seat_quota - 1 + WHERE fk_ms_registration_counter_id = $1 + AND date = $2 + AND seat_quota > 0 + RETURNING seat_quota + ` + + err = dbConn.QueryRowContext(ctx, updateQuery, loketID, date).Scan(&newUsedQuota) + if err != nil { + return 0, fmt.Errorf("failed to decrement quota: %w", err) + } + + logger.Info("Quota decremented successfully", map[string]interface{}{ + "loket_id": loketID, + "new_used_quota": newUsedQuota, + }) + + return newUsedQuota, nil +} + +func (h *TiketHandler) GetLoketIDByTicketID(ctx context.Context, dbConn *sqlx.DB, ticketID int64) (loketID int, err error) { + query := ` + SELECT fk_ms_registration_counter_id + FROM "transaction".tr_patient_visit_healthcare_service + WHERE id = $1 + ` + + err = dbConn.QueryRowContext(ctx, query, ticketID).Scan(&loketID) + if err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("ticket not found") + } + return 0, fmt.Errorf("failed to get loket ID: %w", err) + } + + return loketID, nil +} diff --git a/internal/handlers/transaction/tiket.go b/internal/handlers/transaction/tiket.go new file mode 100644 index 0000000..138a8aa --- /dev/null +++ b/internal/handlers/transaction/tiket.go @@ -0,0 +1,807 @@ +package transaction + +import ( + "api-service/internal/config" + "api-service/internal/database" + "api-service/internal/models" + "api-service/internal/models/transaction" + "api-service/pkg/logger" + "context" + "database/sql" + "fmt" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "log" + "net/http" + "strconv" + "sync" + "time" +) + +var ( + db database.Service + once sync.Once + validate *validator.Validate +) + +func validated(fl validator.FieldLevel) bool { + return models.IsValidStatus(fl.Field().String()) +} + +func init() { + once.Do(func() { + db = database.New(config.LoadConfig()) + validate = validator.New() + + // Register custom validations if needed + validate.RegisterValidation("retribusi_status", validated) + + if db == nil { + logger.Fatal("Failed to initialize database connection") + } + }) +} + +type TiketHandler struct { + db database.Service +} + +func NewtiketHandler() *TiketHandler { + return &TiketHandler{ + db: db, + } +} + +func (h *TiketHandler) respondError(c *gin.Context, message string, err error, statusCode int) { + errorMessage := message + if gin.Mode() == gin.ReleaseMode { + errorMessage = "Internal server error" + } + c.JSON(statusCode, models.ErrorResponse{Error: errorMessage, Code: statusCode, Message: err.Error(), Timestamp: time.Now()}) +} + +func (h *TiketHandler) Createtiket(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var req transaction.CreateTicketRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + log.Println(req) + + kodeID, err := strconv.Atoi(req.IDLoket) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Kode harus berupa angka"}) + return + } + + IDKlinik, err := strconv.Atoi(req.IdKlinik) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Kode harus berupa angka"}) + return + } + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + newUUID := uuid.New().String() + currentDate := time.Now().Format("2006-01-02") + + codeResult, err := h.GetKode(ctx, dbConn, kodeID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + Shift, err := h.GetShift(ctx, dbConn, IDKlinik) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + quotaSeat, usedQuota, err := h.CheckAndLockQuota(ctx, dbConn, kodeID, currentDate) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Loket tidak ditemukan"}) + return + } + logger.Error("Failed to check quota", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check quota"}) + return + } + + availableQuota := quotaSeat - usedQuota + if availableQuota <= 0 { + logger.Warn("Quota loket sudah penuh", map[string]interface{}{ + "id_loket": kodeID, + "quota_seat": quotaSeat, + "used_quota": usedQuota, + "available_quota": availableQuota, + }) + c.JSON(http.StatusConflict, gin.H{ + "error": "Quota loket sudah penuh", + "details": fmt.Sprintf("Quota tersedia: %d", availableQuota), + }) + return + } + + newUsedQuota, err := h.IncrementUsedQuota(ctx, dbConn, kodeID, currentDate, newUUID) + if err != nil { + logger.Error("Failed to update quota", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update quota"}) + return + } + + logger.Info("Quota updated successfully", map[string]interface{}{ + "id_loket": req.IDLoket, + "new_used_quota": newUsedQuota, + "remaining_quota": quotaSeat - newUsedQuota, + }) + + barcodeNumber, err := h.GenerateVisitCode(ctx, dbConn.DB) + if err != nil { + logger.Error("Failed to generate barcode", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate barcode: " + err.Error()}) + return + } + + barcodeData, err := h.insertBarcode(ctx, dbConn.DB, barcodeNumber) + if err != nil { + logger.Error("Failed to insert barcode", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert barcode: " + err.Error()}) + return + } + + ticketNumber, err := h.generateTicketNumber(ctx, dbConn.DB, codeResult, currentDate) + if err != nil { + logger.Error("Failed to generate ticket", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate ticket: " + err.Error()}) + return + } + + idtiket, datetimeStart, err := h.insertTicket(ctx, dbConn.DB, barcodeData.IdBarcode, ticketNumber, codeResult, req.IdKlinik, req.IDLoket, req.IDpembayaran) + if err != nil { + logger.Error("Failed to insert ticket", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert ticket: " + err.Error()}) + return + } + + errVisit := h.insertcalling(ctx, dbConn.DB, req.Idklinikstatus, req.Statuspasien, idtiket) + if errVisit != nil { + logger.Error("Failed to insert calling ", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + err.Error()}) + return + } + + errVisitservice := h.insertvisit(ctx, dbConn.DB, req.Statuspasien, idtiket) + if errVisitservice != nil { + logger.Error("Failed to insert calling ", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + err.Error()}) + return + } + + errVisit2 := h.insertcalling(ctx, dbConn.DB, req.Idklinikstatus, req.Statuspasien2, idtiket) + if errVisit2 != nil { + logger.Error("Failed to insert calling ", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + err.Error()}) + return + } + + errVisitservice2 := h.insertvisit(ctx, dbConn.DB, req.Statuspasien2, idtiket) + if errVisitservice2 != nil { + logger.Error("Failed to insert calling ", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + err.Error()}) + return + } + + logger.Info("Barcode and ticket generated successfully", map[string]interface{}{ + "barcode": barcodeNumber, + "ticket": req.IDLoket + ticketNumber, + "visit_id": barcodeData.IdBarcode, + }) + + resbarcodeData := &transaction.BarcodeData{ + IdBarcode: barcodeData.IdBarcode, + Ticket: codeResult + ticketNumber, + Barcode: barcodeNumber, + NamaKlinik: req.NamaKlinik, + CreatedAt: barcodeData.CreatedAt, + Active: true, + Shift: "shift" + Shift, + Dokter: req.NamaDokter, + DatetimeStart: datetimeStart, + RemainingQuota: strconv.Itoa(quotaSeat - newUsedQuota), + } + + c.JSON(http.StatusCreated, transaction.BarcodeResponse{ + Message: "Barcode berhasil di-generate", + Data: resbarcodeData, + }) +} + +func (h *TiketHandler) GetKode(ctx context.Context, dbConn *sqlx.DB, kodeID int) (string, error) { + var code string + query := `SELECT mrc.code FROM master.ms_registration_counter mrc WHERE mrc.id = $1` + + err := dbConn.QueryRowContext(ctx, query, kodeID).Scan(&code) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("kode dengan id %d tidak ditemukan", kodeID) + } + return "", fmt.Errorf("failed to get kode: %w", err) + } + + return code, nil +} + +func (h *TiketHandler) GetShift(ctx context.Context, dbConn *sqlx.DB, IDKlinik int) (string, error) { + var shift string + query := ` select mhss.shift_number from master.ms_healthcare_service mhs + inner join master.ms_healthcare_service_shift mhss on mhss.fk_ms_healthcare_service_id = mhs.id + where mhs.id = $1` + + err := dbConn.QueryRowContext(ctx, query, IDKlinik).Scan(&shift) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("kode dengan id %d tidak ditemukan", IDKlinik) + } + return "", fmt.Errorf("failed to get kode: %w", err) + } + + return shift, nil +} + +func (h *TiketHandler) GenerateVisitCode(ctx context.Context, dbConn *sql.DB) (string, error) { + today := time.Now().Format("060102") // Format: YYMMDD + + query := ` + SELECT tpv.visit_code::TEXT + FROM transaction.tr_patient_visit tpv + WHERE tpv.visit_code::TEXT LIKE $1 || '%' + AND tpv.registration_datetime >= CURRENT_DATE + AND tpv.registration_datetime < CURRENT_DATE + INTERVAL '1 day' + ORDER BY tpv.visit_code DESC + LIMIT 1 + ` + + var lastVisitCode string + err := dbConn.QueryRowContext(ctx, query, today).Scan(&lastVisitCode) + + var newNumber int + if err == sql.ErrNoRows { + newNumber = 1 + } else if err != nil { + return "", fmt.Errorf("failed to get last visit code: %w", err) + } else { + + if len(lastVisitCode) >= 11 { + lastNumberStr := lastVisitCode[6:] // Ambil 5 digit terakhir + lastNumber, _ := strconv.Atoi(lastNumberStr) + newNumber = lastNumber + 1 + } else { + newNumber = 1 + } + } + + // Format: YYMMDD + 5 digit dengan leading zero + newVisitCode := fmt.Sprintf("%s%05d", today, newNumber) + + return newVisitCode, nil +} + +func (h *TiketHandler) insertBarcode(ctx context.Context, dbConn *sql.DB, barcode string) (*transaction.BarcodeData, error) { + barcodeInt, err := strconv.ParseInt(barcode, 10, 64) + if err != nil { + return nil, fmt.Errorf("failed to convert barcode to integer: %w", err) + } + + query := ` + INSERT INTO transaction.tr_patient_visit + (visit_code, registration_datetime,active) + VALUES + ($1, NOW(),true) + RETURNING id,visit_code, registration_datetime, active + ` + + var barcodeData transaction.BarcodeData + var visitCodeInt int64 + + err = dbConn.QueryRowContext(ctx, query, barcodeInt).Scan( + &barcodeData.IdBarcode, + &visitCodeInt, + &barcodeData.CreatedAt, + &barcodeData.Active, + ) + + if err != nil { + return nil, fmt.Errorf("failed to insert barcode: %w", err) + } + + barcodeData.Barcode = strconv.FormatInt(visitCodeInt, 10) + + return &barcodeData, nil +} + +// func (h *TiketHandler) generateTicketNumber(ctx context.Context, dbConn *sql.DB, healthcareServiceCode string, currentDate string) (string, error) { +// query := ` +// SELECT ticket +// FROM transaction.tr_patient_visit_healthcare_service tpvhs +// WHERE tpvhs.healtcare_service_code = $1 +// AND DATE(tpvhs.datetime_start) = $2 +// ORDER BY tpvhs.datetime_start DESC, tpvhs.id DESC +// LIMIT 1 +// ` +// +// var lastTicket string +// err := dbConn.QueryRowContext(ctx, query, healthcareServiceCode, currentDate).Scan(&lastTicket) +// +// var nextNumber int +// +// if err == sql.ErrNoRows { +// nextNumber = 1 +// logger.Info("No ticket found for today, starting from 001", map[string]interface{}{ +// "code": healthcareServiceCode, +// }) +// } else if err != nil { +// return "", fmt.Errorf("failed to query last ticket: %w", err) +// } else { +// lastNumber, err := strconv.Atoi(lastTicket) +// if err != nil { +// nextNumber = 1 +// logger.Warn("Failed to parse ticket number, resetting to 001", map[string]interface{}{ +// "last_ticket": lastTicket, +// "error": err.Error(), +// }) +// } else { +// nextNumber = lastNumber + 1 +// } +// +// logger.Info("Found last ticket", map[string]interface{}{ +// "last_ticket": lastTicket, +// "next_number": nextNumber, +// }) +// } +// +// if nextNumber > 999 { +// return "", fmt.Errorf("maximum ticket limit reached for today (999)") +// } +// +// ticketNumber := fmt.Sprintf("%03d", nextNumber) +// +// return ticketNumber, nil +// } +func (h *TiketHandler) generateTicketNumber(ctx context.Context, dbConn *sql.DB, healthcareServiceCode string, currentDate string) (string, error) { + query := ` + SELECT COALESCE(MAX(CAST(ticket AS INTEGER)), 0) as max_ticket + FROM transaction.tr_patient_visit_healthcare_service tpvhs + WHERE tpvhs.healtcare_service_code = $1 + AND DATE(tpvhs.datetime_start) = $2 + ` + + var maxTicket int + err := dbConn.QueryRowContext(ctx, query, healthcareServiceCode, currentDate).Scan(&maxTicket) + + if err != nil { + return "", fmt.Errorf("failed to query max ticket: %w", err) + } + + nextNumber := maxTicket + 1 + + logger.Info("Generating next ticket number", map[string]interface{}{ + "code": healthcareServiceCode, + "date": currentDate, + "max_ticket": maxTicket, + "next_number": nextNumber, + }) + + if nextNumber > 999 { + return "", fmt.Errorf("maximum ticket limit reached for today (999)") + } + + ticketNumber := fmt.Sprintf("%03d", nextNumber) + + return ticketNumber, nil +} + +func (h *TiketHandler) inserttiket(ctx context.Context, dbcoon *sql.DB, idticket int64, idklinik string) error { + query := ` + INSERT INTO "transaction".tr_patient_visit_healthcare_service_healthcare_service + (fk_tr_patient_visit_healthcare_service_id, fk_ms_healthcare_service_id) + VALUES + ($1, $2) + ` + + _, err := dbcoon.ExecContext(ctx, query, idticket, idklinik) + if err != nil { + return fmt.Errorf("failed to insert ticket relation: %w", err) + } + + return nil +} + +func (h *TiketHandler) insertcalling(ctx context.Context, dbcoon *sql.DB, IdHealthcarestatus string, IdVisitstatus string, idtiket int64) error { + query := ` + INSERT INTO "transaction".tr_patient_visit_healthcare_service_calling + (fk_ref_healthcare_status_id, fk_ref_visit_status_id,fk_ref_healthcare_type_id,fk_tr_patient_visit_healthcare_service_id,active,datetime) + VALUES + ($1,$2,$3,$4,TRUE,NOW()) + ` + + _, err := dbcoon.ExecContext(ctx, query, IdHealthcarestatus, IdVisitstatus, IdHealthcarestatus, idtiket) + if err != nil { + return fmt.Errorf("failed to insert ticket relation: %w", err) + } + + return nil +} + +func (h *TiketHandler) insertvisit(ctx context.Context, dbcoon *sql.DB, IdVisitstatus string, idtiket int64) error { + query := ` + INSERT INTO "transaction".tr_patient_visit_healthcare_service_visit_status + (fk_ref_visit_status_id,fk_tr_patient_visit_healthcare_service_id,datetime) + VALUES + ($1,$2,NOW()) + ` + + _, err := dbcoon.ExecContext(ctx, query, IdVisitstatus, idtiket) + if err != nil { + return fmt.Errorf("failed to insert ticket relation: %w", err) + } + + return nil +} + +func (h *TiketHandler) insertTicket(ctx context.Context, dbcoon *sql.DB, visitID string, ticket string, healthcareServiceCode string, idKlinik string, idloket string, idpembayaran string) (int64, time.Time, error) { + query := ` + INSERT INTO transaction.tr_patient_visit_healthcare_service + (ticket, healtcare_service_code, fk_tr_patient_visit_id,fk_ms_healthcare_service_id,fk_ms_registration_counter_id,fk_ref_payment_type_id,datetime_start,active) + VALUES + ($1,$2,$3,$4,$5,$6,NOW(),true) + RETURNING id, datetime_start + ` + var id int64 + var datetimeStart time.Time + + err := dbcoon.QueryRowContext(ctx, query, ticket, healthcareServiceCode, visitID, idKlinik, idloket, idpembayaran).Scan(&id, &datetimeStart) + if err != nil { + return 0, time.Time{}, fmt.Errorf("failed to insert ticket: %w", err) + } + + return id, datetimeStart, nil +} + +func (h *TiketHandler) Updatecekin(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var req transaction.Checkin + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + log.Println(req) + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + + ticketID, err := h.GetTicketIDByVisitCode(ctx, dbConn, req.Visitcode) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Ticket tidak ditemukan", + "visit_code": req.Visitcode, + }) + return + } + + logger.Error("Failed to get ticket", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve ticket"}) + return + } + + log.Println(ticketID) + + errVisit := h.insertcalling2(ctx, dbConn.DB, req.Idklinikstatus, req.Statuspasien, ticketID) + if errVisit != nil { + logger.Error("Failed to insert calling", map[string]interface{}{"error": errVisit.Error()}) // PERBAIKAN: errVisit bukan err + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + errVisit.Error()}) // PERBAIKAN + return + } + + errVisitservice := h.insertvisit2(ctx, dbConn.DB, req.Statuspasien, ticketID) + if errVisitservice != nil { + logger.Error("Failed to insert visit service", map[string]interface{}{"error": errVisitservice.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert visit service: " + errVisitservice.Error()}) // PERBAIKAN + return + } + + errVisit2 := h.insertcalling2(ctx, dbConn.DB, req.Idklinikstatus2, req.Statuspasien2, ticketID) + if errVisit2 != nil { + logger.Error("Failed to insert calling 2", map[string]interface{}{"error": errVisit2.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling 2: " + errVisit2.Error()}) // PERBAIKAN + return + } + + errVisitservice2 := h.insertvisit2(ctx, dbConn.DB, req.Statuspasien2, ticketID) + if errVisitservice2 != nil { + logger.Error("Failed to insert visit service 2", map[string]interface{}{"error": errVisitservice2.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert visit service 2: " + errVisitservice2.Error()}) // PERBAIKAN + return + } + + err = h.UpdateCheckin(ctx, dbConn, &req) + if err != nil { + logger.Error("Failed to update check-in", map[string]interface{}{ + "error": err.Error(), + "visitCode": req.Visitcode, + }) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to update check-in", + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Check-in berhasil", + "data": req.Visitcode, + }) +} + +func (h *TiketHandler) UpdateCheckin(ctx context.Context, dbConn *sqlx.DB, req *transaction.Checkin) error { + query := ` + UPDATE "transaction".tr_patient_visit_healthcare_service tpvhs + SET check_in = true, check_in_datetime = NOW() + WHERE fk_tr_patient_visit_id = ( + SELECT id + FROM "transaction".tr_patient_visit tpv + WHERE tpv.visit_code = $1 + ) + ` + + result, err := dbConn.ExecContext(ctx, query, req.Visitcode) + if err != nil { + return fmt.Errorf("failed to execute update query: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("visit code tidak ditemukan: %s", req.Visitcode) + } + + logger.Info("Check-in updated successfully", map[string]interface{}{ + "visitCode": req.Visitcode, + "rowsAffected": rowsAffected, + }) + + return nil +} + +func (h *TiketHandler) GetTicketIDByVisitCode(ctx context.Context, dbConn *sqlx.DB, visitCode string) (*transaction.TicketID, error) { + query := ` + SELECT tpvhs.id + FROM "transaction".tr_patient_visit tpv + INNER JOIN "transaction".tr_patient_visit_healthcare_service tpvhs + ON tpvhs.fk_tr_patient_visit_id = tpv.id + WHERE tpv.visit_code = $1 + and tpvhs.active = true + ` + + var ticketID transaction.TicketID + + err := dbConn.QueryRowContext(ctx, query, visitCode).Scan(&ticketID.ID) + if err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, fmt.Errorf("failed to get ticket ID: %w", err) + } + + logger.Info("Ticket ID retrieved successfully", map[string]interface{}{ + "visitCode": visitCode, + "ticketID": ticketID.ID, + }) + + return &ticketID, nil +} + +func (h *TiketHandler) insertcalling2(ctx context.Context, dbcoon *sql.DB, IdHealthcarestatus string, IdVisitstatus string, idtiket *transaction.TicketID) error { + query := ` + INSERT INTO "transaction".tr_patient_visit_healthcare_service_calling + (fk_ref_healthcare_status_id, fk_ref_visit_status_id, fk_ref_healthcare_type_id, fk_tr_patient_visit_healthcare_service_id, active, datetime) + VALUES + ($1, $2, $3, $4, TRUE, NOW()) + ` + + _, err := dbcoon.ExecContext(ctx, query, IdHealthcarestatus, IdVisitstatus, IdHealthcarestatus, idtiket.ID) + if err != nil { + return fmt.Errorf("failed to insert calling: %w", err) + } + + return nil +} +func (h *TiketHandler) insertvisit2(ctx context.Context, dbcoon *sql.DB, IdVisitstatus string, idtiket *transaction.TicketID) error { + query := ` + INSERT INTO "transaction".tr_patient_visit_healthcare_service_visit_status + (fk_ref_visit_status_id, fk_tr_patient_visit_healthcare_service_id, datetime) + VALUES + ($1, $2, NOW()) + ` + + _, err := dbcoon.ExecContext(ctx, query, IdVisitstatus, idtiket.ID) + if err != nil { + return fmt.Errorf("failed to insert visit status: %w", err) + } + + return nil +} + +func (h *TiketHandler) CreateStatus(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var req transaction.UpdateTiket + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + log.Println(req) + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + ticketID, err := h.GetTicketIDByVisitCode(ctx, dbConn, req.Visitcode) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Ticket tidak ditemukan", + "visit_code": req.Visitcode, + }) + return + } + + logger.Error("Failed to get ticket", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve ticket"}) + return + } + + errVisit := h.insertcalling2(ctx, dbConn.DB, req.Idklinikstatus, req.Statuspasien, ticketID) + if errVisit != nil { + logger.Error("Failed to insert calling", map[string]interface{}{"error": errVisit.Error()}) // PERBAIKAN: errVisit bukan err + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + errVisit.Error()}) // PERBAIKAN + return + } + + errVisitservice := h.insertvisit2(ctx, dbConn.DB, req.Statuspasien, ticketID) + if errVisitservice != nil { + logger.Error("Failed to insert visit service", map[string]interface{}{"error": errVisitservice.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert visit service: " + errVisitservice.Error()}) // PERBAIKAN + return + } + + errVisit2 := h.insertcalling2(ctx, dbConn.DB, req.Idklinikstatus2, req.Statuspasien2, ticketID) + if errVisit2 != nil { + logger.Error("Failed to insert calling 2", map[string]interface{}{"error": errVisit2.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling 2: " + errVisit2.Error()}) // PERBAIKAN + return + } + + errVisitservice2 := h.insertvisit2(ctx, dbConn.DB, req.Statuspasien2, ticketID) + if errVisitservice2 != nil { + logger.Error("Failed to insert visit service 2", map[string]interface{}{"error": errVisitservice2.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert visit service 2: " + errVisitservice2.Error()}) // PERBAIKAN + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "Update berhasil", + "data": req.Visitcode, + }) +} +func (h *TiketHandler) SelesaiStatus(c *gin.Context) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var req transaction.TiketSelesai + if err := c.ShouldBindJSON(&req); err != nil { + h.respondError(c, "Invalid request body", err, http.StatusBadRequest) + return + } + if err := validate.Struct(&req); err != nil { + h.respondError(c, "Validation failed", err, http.StatusBadRequest) + return + } + log.Println(req) + loketID, err := strconv.Atoi(req.IDLoket) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID Loket harus berupa angka"}) + return + } + + dbConn, err := h.db.GetSQLXDB("postgres_antrean") + if err != nil { + logger.Error("Database connection failed", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection failed"}) + return + } + ticketID, err := h.GetTicketIDByVisitCode(ctx, dbConn, req.Visitcode) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{ + "error": "Ticket tidak ditemukan", + "visit_code": req.Visitcode, + }) + return + } + + logger.Error("Failed to get ticket", map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve ticket"}) + return + } + + errVisit := h.insertcalling2(ctx, dbConn.DB, req.Idklinikstatus, req.Statuspasien, ticketID) + if errVisit != nil { + logger.Error("Failed to insert calling", map[string]interface{}{"error": errVisit.Error()}) // PERBAIKAN: errVisit bukan err + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert calling: " + errVisit.Error()}) // PERBAIKAN + return + } + + errVisitservice := h.insertvisit2(ctx, dbConn.DB, req.Statuspasien, ticketID) + if errVisitservice != nil { + logger.Error("Failed to insert visit service", map[string]interface{}{"error": errVisitservice.Error()}) // PERBAIKAN + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert visit service: " + errVisitservice.Error()}) // PERBAIKAN + return + } + + currentDate := time.Now().Format("2006-01-02") + newUsedQuota, err := h.MinQuota(ctx, dbConn, loketID, currentDate) + if err != nil { + logger.Error("Failed to return quota", map[string]interface{}{ + "error": err.Error(), + "loket_id": loketID, + "ticket_id": ticketID, + "visit_code": req.Visitcode, + }) + } else { + logger.Info("Quota returned successfully", map[string]interface{}{ + "loket_id": loketID, + "new_used_quota": newUsedQuota, + "ticket_id": ticketID, + "visit_code": req.Visitcode, + }) + } + c.JSON(http.StatusOK, gin.H{ + "message": "Update status selesai berhasil", + "data": req.Visitcode, + "Quota": newUsedQuota, + }) +} diff --git a/internal/models/antrean/antrean.go b/internal/models/antrean/antrean.go new file mode 100644 index 0000000..1ef943e --- /dev/null +++ b/internal/models/antrean/antrean.go @@ -0,0 +1,47 @@ +package antrean + +type PermissionGetResponse struct { + Message string `json:"message"` + Data []*PermisssionAntrean `json:"data"` + Meta map[string]interface{} `json:"meta"` +} +type PermisssionAntrean struct { + IDpermission int `json:"id" db:"perid"` + CreatePermission bool `json:"create" db:"pecreate"` + ReadPermission bool `json:"read" db:"peread"` + UpdatePermission bool `json:"update" db:"peupdate"` + DisablePermission bool `json:"disable" db:"pedisable"` + DeletePermission bool `json:"delete" db:"pedelete"` + Active bool `json:"active" db:"active"` + PageName string `json:"pagename" db:"pagename"` + PagesID int `json:"pagesID" db:"pgid"` + Level *int `json:"level,omitempty" db:"level"` // tambahan + Sort *int `json:"sort,omitempty" db:"sort"` // tambahan + Parent *int `json:"parent,omitempty" db:"parent"` // tambahan +} + +type CreatePermissionRequest struct { + CreatePermission bool `json:"create" binding:"required"` + ReadPermission bool `json:"read" binding:"required"` + UpdatePermission bool `json:"update" binding:"required"` + DisablePermission bool `json:"disable" binding:"required"` + DeletePermission bool `json:"delete" binding:"required"` + Active bool `json:"active" binding:"required"` + PagesID int `json:"pagesID" binding:"required,min=1"` + Roles []string `json:"roles" binding:"required,dive,min=1"` + Groups []string `json:"groups" binding:"required,dive,min=1"` +} + +type UpdatePermission struct { + IDpermission int `json:"id" db:"id"` + CreatePermission *bool `json:"create" db:"pecreate"` + ReadPermission *bool `json:"read" db:"peread"` + UpdatePermission *bool `json:"update" db:"peupdate"` + DisablePermission *bool `json:"disable" db:"pedisable"` + DeletePermission *bool `json:"delete" db:"pedelete"` + Active *bool `json:"active" db:"active"` + PagesID *int `json:"pagesID" db:"pgid"` + PageName string `json:"page_name" db:"name"` + Roles []string `json:"roles" db:"role_keycloak"` + Groups []string `json:"groups" db:"group_keycloak"` +} diff --git a/internal/models/master/dokter.go b/internal/models/master/dokter.go new file mode 100644 index 0000000..f2337ad --- /dev/null +++ b/internal/models/master/dokter.go @@ -0,0 +1,23 @@ +package master + +type ListDokter struct { + GelarDepan string `json:"gelardepan" db:"gelardepan"` + NamaLengkap string `json:"namalengkap" db:"namalengkap"` + GelarBelakang string `json:"gelarbelakang" db:"gelarbelakang"` + Spesialis string `json:"Spesialis" db:"Spesialis"` + Subspesialis string `json:"Subspesialis" db:"Subspesialis"` +} + +// DokterFormatted - Data dokter yang sudah diformat +type DokterFormatted struct { + NamaLengkapFormatted string `json:"nama_lengkap"` + Spesialis string `json:"Spesialis"` + Subspesialis string `json:"Subspesialis"` +} + +// DokterResponse - Response API +type DokterResponse struct { + Message string `json:"message"` + Data []*DokterFormatted `json:"data"` + Meta map[string]interface{} `json:"meta"` +} diff --git a/internal/models/master/loket.go b/internal/models/master/loket.go new file mode 100644 index 0000000..1f39390 --- /dev/null +++ b/internal/models/master/loket.go @@ -0,0 +1,125 @@ +package master + +import ( + "database/sql" + "encoding/json" + "time" +) + +type TicketQueue struct { + ID int64 `json:"id" db:"id"` + HealthcareServiceCode sql.NullString `json:"healthcare_service_code" db:"healtcare_service_code"` + Ticket string `json:"ticket" db:"ticket"` + DatetimeStart time.Time `json:"datetime_start" db:"datetime_start"` + VisitCode string `json:"visit_code" db:"visit_code"` + HealthcareServiceName sql.NullString `json:"healthcare_service_name" db:"name"` + HealthcareServiceCodeMS sql.NullString `json:"healthcare_service_code_ms" db:"code"` + ShiftNumber sql.NullInt64 `json:"shift_number" db:"shift_number"` + PaymentTypeName sql.NullString `json:"payment_type_name" db:"payment"` + Posisi sql.NullString `json:"-" db:"posisi"` + Deskripsi sql.NullString `json:"-" db:"deskripsi"` + WaktuPosisi sql.NullTime `json:"-" db:"waktuposisi"` +} + +type TicketQueueResponse struct { + Message string `json:"message"` + KuotaLoket int `json:"kuotaloket"` + Data []*LoketTiketResponse `json:"data"` + Meta map[string]interface{} `json:"meta"` +} + +func (t TicketQueue) MarshalJSON() ([]byte, error) { + type Alias TicketQueue + aux := &struct { + HealthcareServiceCode *string `json:"healthcare_service_code,omitempty"` + DateStart *string `json:"date_start,omitempty"` + TimeStart *string `json:"time_start,omitempty"` + DatetimeStartFormatted *string `json:"datetime_start_formatted,omitempty"` + HealthcareServiceName *string `json:"healthcare_service_name,omitempty"` + HealthcareServiceCodeMS *string `json:"healthcare_service_code_ms,omitempty"` + ShiftNumber *int `json:"shift_number,omitempty"` + PaymentTypeName *string `json:"payment_type_name,omitempty"` + WaktuPosisi1 *string `json:"waktu_posisi,omitempty"` + TanggalPosisi *string `json:"tanggal_posisi,omitempty"` + *Alias + }{ + Alias: (*Alias)(&t), + } + + if t.HealthcareServiceCode.Valid { + aux.HealthcareServiceCode = &t.HealthcareServiceCode.String + } + + if !t.DatetimeStart.IsZero() { + + dateStr := t.DatetimeStart.Format("02 Jan 2006") + aux.DateStart = &dateStr + + timeStr := t.DatetimeStart.Format("15:04:05") + aux.TimeStart = &timeStr + + formattedStr := t.DatetimeStart.Format("02 Jan 2006, 15:04") + aux.DatetimeStartFormatted = &formattedStr + } + + if t.HealthcareServiceName.Valid { + aux.HealthcareServiceName = &t.HealthcareServiceName.String + } + + if t.HealthcareServiceCodeMS.Valid { + aux.HealthcareServiceCodeMS = &t.HealthcareServiceCodeMS.String + } + + if t.ShiftNumber.Valid { + shift := int(t.ShiftNumber.Int64) + aux.ShiftNumber = &shift + } + + if t.PaymentTypeName.Valid { + aux.PaymentTypeName = &t.PaymentTypeName.String + } + + return json.Marshal(aux) +} + +type LoketTiketResponse struct { + IdLoketTiket string `json:"idtiket"` + TiketLoket string `json:"ticket"` + KlinikTiketLoket string `json:"klinik"` + BarcodeTiketLoket string `json:"barcode"` + TanggalTiketLoket string `json:"tanggal"` + WaktuTiketLoket string `json:"waktu"` + ShiftTiketLoket int `json:"shift"` + Pembayaran string `json:"pembayaran"` + Posisi []Posisi `json:"posisi"` +} +type Posisi struct { + Posisi string `json:"posisi,omitempty"` + Deskripsi string `json:"deskripsi,omitempty"` + WaktuPosisi string `json:"waktuposisi,omitempty"` + TanggalPosisi string `json:"tanggalposisi,omitempty"` +} + +type KlinikRuangan struct { + IDKlinik sql.NullString `json:"idklinik" db:"idklinik"` + NamaKlinik sql.NullString `json:"namaklinik" db:"namaklinik"` + IDRuangan sql.NullString `json:"idruangan" db:"idruangan"` + NamaRuangan sql.NullString `json:"namaruangan" db:"namaruangan"` +} + +type RuanganDetail struct { + IDRuangan string `json:"idruangan"` + NamaRuangan string `json:"namaruangan"` +} + +type KlinikRuanganGrouped struct { + IDKlinik string `json:"idklinik"` + NamaKlinik string `json:"namaklinik"` + Ruangan []RuanganDetail `json:"ruangan"` +} + +type KlinikRuanganResponse struct { + Message string `json:"message"` + Data []*KlinikRuanganGrouped `json:"data"` + Meta map[string]interface{} `json:"meta"` +} diff --git a/internal/models/master/poliklinik.go b/internal/models/master/poliklinik.go new file mode 100644 index 0000000..cabdbc6 --- /dev/null +++ b/internal/models/master/poliklinik.go @@ -0,0 +1,141 @@ +package master + +import ( + "database/sql" + "encoding/json" +) + +type ListPoli struct { + TipeAnjungan string `json:"Tipe_Anjungan" db:"tipeanjungan"` + Active bool `json:"active" db:"active"` + IDKlinik sql.NullString `json:"idklinik" db:"idklinik"` + NamaKlinik sql.NullString `json:"namaklinik" db:"namaklinik"` + Code string `json:"code" db:"code"` + IDSpesialis sql.NullInt64 `json:"idspesialis" db:"idspesialis"` + Spesialis sql.NullString `json:"Spesialis" db:"spesialis"` + Hari string `json:"hari" db:"hari"` + JamMulai string `json:"jammulai" db:"mulai"` + JamSelesai string `json:"jamselesai" db:"selesai"` + JamAnjungan string `json:"mulaianjungan" db:"mulaianjungan"` + Selesaianjungan string `json:"selesaianjungan" db:"selesaianjungan"` + Shift sql.NullInt64 `json:"shift" db:"shift"` + Kuota sql.NullInt64 `json:"kuota" db:"kuota"` + IDloket sql.NullString `json:"idloket" db:"idloket"` + Namaloket sql.NullString `json:"namaloket" db:"namaloket"` + KodeLoket sql.NullString `json:"kodeloket" db:"kodeloket"` + QuotaLoket sql.NullInt64 `json:"quota_loket" db:"quota_loket"` + AvailableQuota sql.NullInt64 `json:"available_quota" db:"available_quota"` + IdPayment sql.NullInt64 `json:"idpayment" db:"idpayment"` + PaymentType sql.NullString `json:"paymenttype" db:"paymenttype"` +} + +type LoketDetail struct { + IDLoket string `json:"idloket"` + NamaLoket string `json:"namaloket"` + KodeLoket string `json:"kodeloket"` + AvailableQuota int `json:"available_quota"` +} + +type PoliklinikGrouped struct { + TipeAnjungan string `json:"Tipe_Anjungan"` + Active bool `json:"active"` + IDKlinik string `json:"idklinik"` + NamaKlinik string `json:"namaklinik"` + Code string `json:"code"` + Shift sql.NullInt64 `json:"shift"` + Kuota sql.NullInt64 `json:"kuota"` + Jadwal []ScheduleDetail `json:"jadwal"` + Spesialis []SpesialisDetail `json:"spesialis"` + Loket []LoketDetail `json:"loket"` + Payment []PaymentDetail `json:"payment"` +} + +type SpesialisDetail struct { + IDSpesialis int `json:"idspesialis"` + Spesialis string `json:"Spesialis"` +} + +type PoliklinikResponse struct { + Message string `json:"message"` + Data []*PoliklinikGrouped `json:"data"` + Meta map[string]interface{} `json:"meta"` +} +type ScheduleDetail struct { + Hari string `json:"hari"` + JamOperasional string `json:"jam_operasional"` +} + +type PaymentDetail struct { + IDPayment int `json:"idpayment"` + PaymentType string `json:"payment_type"` +} +type ListLoket struct { + IdLoket string `json:"idloket" db:"idloket"` + NamaLoket string `json:"namaloket" db:"namaloket"` + KodeLoket string `json:"kodeloket" db:"kodeloket"` + KuotaLoket string `json:"kuotaloket" db:"kuotaloket"` + LoketAktif bool `json:"loketatif" db:"loketaktif"` + JenisLoket string `json:"jenisoket" db:"jenisloket"` + TipeLoket string `json:"tipeloket" db:"tipeloket"` + TipeVisit string `json:"tipevisit" db:"tipevisit"` + Pembayaran string `json:"pembayaran" db:"pembayaran"` + IDKlinik string `json:"idklinik" db:"idklinik"` + NamaKlinik string `json:"namaklinik" db:"namaklinik"` +} + +type LoketResponse struct { + Message string `json:"message"` + Data []*LoketGrouped `json:"data"` + Meta map[string]interface{} `json:"meta"` +} + +type LoketGrouped struct { + IdLoket string `json:"idloket"` + TipeAnjungan string `json:"namaloket"` + KodeLoket string `json:"kodeloket"` + KuotaLoket string `json:"kuotaloket"` + LoketAktif bool `json:"loketaktif"` + JenisLoket string `json:"jenisloket"` + TipeLoket string `json:"tipeloket"` + TipeVisit []ListVisit `json:"tipevisit"` + NamaKlinik []ListKlinik `json:"spesialis"` + NamaPembayaran []ListPembayaran `json:"pembayaran"` +} + +type ListVisit struct { + TipeVisit string `json:"tipevisit"` +} + +type ListKlinik struct { + IDKlinik string `json:"idklinik"` + NamaKlinik string `json:"namaklinik"` +} + +type ListPembayaran struct { + IDPayment int `json:"idpayment"` + NamaPembayaran string `json:"pembayaran"` +} + +func (p PoliklinikGrouped) MarshalJSON() ([]byte, error) { + type Alias PoliklinikGrouped + aux := &struct { + Shift *int `json:"shift,omitempty"` + Kuota *int `json:"kuota,omitempty"` + *Alias + }{ + Alias: (*Alias)(&p), + } + + // Convert sql.NullInt64 to pointer + if p.Shift.Valid { + shift := int(p.Shift.Int64) + aux.Shift = &shift + } + + if p.Kuota.Valid { + kuota := int(p.Kuota.Int64) + aux.Kuota = &kuota + } + + return json.Marshal(aux) +} diff --git a/internal/models/pasien/pasien.go b/internal/models/pasien/pasien.go index 23021f7..c207242 100644 --- a/internal/models/pasien/pasien.go +++ b/internal/models/pasien/pasien.go @@ -395,3 +395,61 @@ type PasienFilter struct { DateTo *time.Time `json:"date_to,omitempty" form:"date_to"` Status *string `json:"status,omitempty" form:"status"` } + +type PasienData struct { + Nomr string `json:"nomr" db:"nomr"` + NamaLengkap string `json:"nama" db:"nama"` + NoTelp string `json:"notelp" db:"notelp"` + JenisKelamin string `json:"jeniskelamin" db:"jeniskelamin"` + TTL time.Time `json:"tgllahir" db:"tgllahir"` + Alamat string `json:"alamat" db:"alamat"` + IdProvinsi int `json:"idprovinsi" db:"idprovinsi"` + IdKota int `json:"idkota" db:"idkota"` + IdKecamatan int `json:"idkecamatan" db:"idkecamatan"` + IdKelurahan int `json:"idkelurahan" db:"idkelurahan"` + NamaProvinsi string `json:"namaprovinsi" db:"namaprovinsi"` + NamaKota string `json:"namakota" db:"namakota"` + NamaKecamatan string `json:"namakecamatan" db:"namakecamatan"` + NamaKelurahan string `json:"namakelurahan" db:"namakelurahan"` +} + +func (p PasienData) MarshalJSON() ([]byte, error) { + // Struct untuk output JSON + output := struct { + Nomr string `json:"nomr"` + NamaLengkap string `json:"nama"` + NoTelp string `json:"notelp"` + JenisKelamin string `json:"jeniskelamin"` + TglLahir string `json:"tgllahir"` + Alamat string `json:"alamat"` + IdProvinsi int `json:"idprovinsi"` + IdKota int `json:"idkota"` + IdKecamatan int `json:"idkecamatan"` + IdKelurahan int `json:"idkelurahan"` + NamaProvinsi string `json:"namaprovinsi"` + NamaKota string `json:"namakota"` + NamaKecamatan string `json:"namakecamatan"` + NamaKelurahan string `json:"namakelurahan"` + }{ + Nomr: p.Nomr, + NamaLengkap: p.NamaLengkap, + NoTelp: p.NoTelp, + JenisKelamin: p.JenisKelamin, + Alamat: p.Alamat, + IdProvinsi: p.IdProvinsi, + IdKota: p.IdKota, + IdKecamatan: p.IdKecamatan, + IdKelurahan: p.IdKelurahan, + NamaProvinsi: p.NamaProvinsi, + NamaKota: p.NamaKota, + NamaKecamatan: p.NamaKecamatan, + NamaKelurahan: p.NamaKelurahan, + } + + // ✅ Format tanggal lahir + if !p.TTL.IsZero() { + output.TglLahir = p.TTL.Format("2006-01-02") + } + + return json.Marshal(output) +} diff --git a/internal/models/reference/visit.go b/internal/models/reference/visit.go new file mode 100644 index 0000000..363ef7d --- /dev/null +++ b/internal/models/reference/visit.go @@ -0,0 +1,15 @@ +package reference + +type VisitStatus struct { + IDVisit int `json:"idvisit" db:"idvisit"` + NamaStatus string `json:"namastatus" db:"namastatus"` + DeskripsiStatus string `json:"deskripsistatus" db:"deskripsistatus"` + IDKlinikStatus int `json:"idklinikstatus" db:"idklinikstatus"` + NamaStatusKlinik string `json:"namastatusklinik" db:"namastatusklinik"` +} + +type VisitStatusResponse struct { + Message string `json:"message"` + Data []*VisitStatus `json:"data"` + Meta map[string]interface{} `json:"meta"` +} diff --git a/internal/models/transaction/tiket.go b/internal/models/transaction/tiket.go new file mode 100644 index 0000000..30b48a6 --- /dev/null +++ b/internal/models/transaction/tiket.go @@ -0,0 +1,132 @@ +package transaction + +import ( + "encoding/json" + "time" +) + +type TicketRequest struct { + KodeLayanan string `json:"kode_layanan" binding:"required"` +} + +type TicketResponse struct { + Message string `json:"message"` + Data *TicketData `json:"data"` +} +type TicketData struct { + Ticket string `json:"ticket" db:"ticket"` + CheckIn bool `json:"check_in" db:"check_in"` + CheckInDate string `json:"check_in_datetime" db:"check_in_datetime"` + DateStart string `json:"datetime_start" db:"datetime_start"` + DateEnd string `json:"datetime_end" db:"datetime_end"` +} + +type CreateTicketRequest struct { + IDLoket string `json:"idloket" db:"id" binding:"required"` + NamaDokter string `json:"dokter"` + IdKlinik string `json:"idklinik"` + NamaKlinik string `json:"namaklinik"` + IDpembayaran string `json:"idpembayaran"` + Statuspasien string `json:"statuspasien"` + Statuspasien2 string `json:"statuspasien2"` + Idklinikstatus string `json:"idklinikstatus"` +} + +type CreateTicketResponse struct { + KodeLoket string `json:"code" db:"code"` +} + +type BarcodeResponse struct { + Message string `json:"message"` + Data *BarcodeData `json:"data"` +} +type BarcodeData struct { + IdBarcode string `json:"id" db:"id"` + Barcode string `json:"barcode" db:"visit_code"` + NamaKlinik string `json:"namaklinik"` + CreatedAt time.Time `json:"-" db:"registration_time"` + Active bool `json:"active" db:"active"` + + Ticket string `json:"ticket"` + Dokter string `json:"dokter"` + Shift string `json:"shift"` + DatetimeStart time.Time `json:"-"` + RemainingQuota string `json:"remaining_quota" db:"remaining_quota"` +} + +type Checkin struct { + Visitcode string `json:"barcode"` + Statuspasien string `json:"statuspasien"` + Statuspasien2 string `json:"statuspasien2"` + Idklinikstatus string `json:"idklinikstatus"` + Idklinikstatus2 string `json:"idklinikstatus2"` +} + +type UpdateTiket struct { + Visitcode string `json:"barcode"` + Statuspasien string `json:"statuspasien"` + Statuspasien2 string `json:"statuspasien2"` + Idklinikstatus string `json:"idklinikstatus"` + Idklinikstatus2 string `json:"idklinikstatus2"` +} + +type TiketSelesai struct { + IDLoket string `json:"idloket"` + Visitcode string `json:"barcode"` + Statuspasien string `json:"statuspasien"` + Idklinikstatus string `json:"idklinikstatus"` +} + +func (b BarcodeData) MarshalJSON() ([]byte, error) { + type Alias BarcodeData + + // Struct untuk output JSON + output := struct { + Id string `json:"id"` + Barcode string `json:"barcode"` + NamaKlinik string `json:"namaklinik"` + TanggalBarcode string `json:"tanggalbarcode"` + WaktuBarcode string `json:"waktubarcode"` + Active bool `json:"active"` + Ticket string `json:"ticket"` + Dokter string `json:"dokter"` + Shift string `json:"shift"` + TanggalTiket string `json:"tanggaltiket"` + WaktuTiket string `json:"waktutiket"` + }{ + Id: b.IdBarcode, + Barcode: b.Barcode, + NamaKlinik: b.NamaKlinik, + Active: b.Active, + Ticket: b.Ticket, + Dokter: b.Dokter, + Shift: b.Shift, + } + + // Format CreatedAt + if !b.CreatedAt.IsZero() { + output.TanggalBarcode = b.CreatedAt.Format("2006-01-02") + output.WaktuBarcode = b.CreatedAt.Format("15:04:05") + } + + // Format DatetimeStart + if !b.DatetimeStart.IsZero() { + output.TanggalTiket = b.DatetimeStart.Format("2006-01-02") + output.WaktuTiket = b.DatetimeStart.Format("15:04:05") + } + + return json.Marshal(output) +} + +type TicketID struct { + ID int `json:"id" db:"id"` +} + +//type Loket struct { +// Barcode +// NoAntrean +// Klinik +// Pembayaran +// Dokter +// Fast +//} diff --git a/internal/routes/v1/routes.go b/internal/routes/v1/routes.go index 9dab660..94cf4c9 100644 --- a/internal/routes/v1/routes.go +++ b/internal/routes/v1/routes.go @@ -3,11 +3,14 @@ package v1 import ( "api-service/internal/config" "api-service/internal/database" + Antrean "api-service/internal/handlers/antrean" authHandlers "api-service/internal/handlers/auth" healthcheckHandlers "api-service/internal/handlers/healthcheck" - + Master "api-service/internal/handlers/master" pasienPasienHandlers "api-service/internal/handlers/pasien" + "api-service/internal/handlers/reference" retribusiHandlers "api-service/internal/handlers/retribusi" + "api-service/internal/handlers/transaction" "api-service/internal/middleware" services "api-service/internal/services/auth" "api-service/pkg/logger" @@ -133,10 +136,42 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { pasienPasienGroup.GET("/dynamic", pasienPasienHandler.GetPasienDynamic) pasienPasienGroup.GET("/", pasienPasienHandler.GetPasien) pasienPasienGroup.GET("/by-age", pasienPasienHandler.GetPasienByAge) - pasienPasienGroup.GET("/:nomr", pasienPasienHandler.GetPasienByNomr) + pasienPasienGroup.GET("/:nomr", pasienPasienHandler.GetPasienByNOMR) + pasienPasienGroup.GET("/search", pasienPasienHandler.SearchPasienByNama) pasienPasienGroup.GET("/by-location", pasienPasienHandler.GetPasienByLocation) } + AntreanHandler := Antrean.NewAntreanHandler() + Antrean := v1.Group("/permission") + Antrean.GET("", AntreanHandler.GetDataPermission) + Antrean.POST("/create", AntreanHandler.CreatePermission) + Antrean.PUT("/update", AntreanHandler.UpdatePermission) + + RefHandler := reference.NewreferenceHandler() + Reference := v1.Group("/reference") + Reference.GET("", RefHandler.Getvisitpasien) + + PoliHandler := Master.NewPoliHandler() + Poli := v1.Group("/klinik") + Poli.GET("/:jenis", PoliHandler.GetAnjungan) + Poli.GET("/loket", PoliHandler.GetLoket) + + DokterHandler := Master.NewDokterHandler() + Dokter := v1.Group("/dokter") + Dokter.GET("/:idklinik", DokterHandler.GetDokterbyspesialis) + + TiketHandler := transaction.NewtiketHandler() + Tiket := v1.Group("/tiket") + Tiket.POST("/generate", TiketHandler.Createtiket) + Tiket.POST("/checkin", TiketHandler.Updatecekin) + Tiket.POST("/update", TiketHandler.CreateStatus) + Tiket.POST("/selesai", TiketHandler.SelesaiStatus) + + LoketHandler := Master.NewLoketGHandler() + Loket := v1.Group("/loket") + Loket.GET("/:loket", LoketHandler.GetLoket) + Loket.GET("/ruang", LoketHandler.GetKlinikRuang) + // ============================================================================= // PROTECTED ROUTES (Authentication Required) // ============================================================================= @@ -156,7 +191,7 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine { // } } - // ============================================================================= + // ===================================a========================================== // DEBUG ROUTES (Publik - Tanpa keamanan ketat) // ============================================================================= router.GET("/debug/token", func(c *gin.Context) { diff --git a/internal/utils/query/builder.go b/internal/utils/query/builder.go index 95360b2..e71aa17 100644 --- a/internal/utils/query/builder.go +++ b/internal/utils/query/builder.go @@ -61,6 +61,8 @@ const ( OpArrayContains FilterOperator = "_array_contains" OpArrayNotContains FilterOperator = "_array_ncontains" OpArrayLength FilterOperator = "_array_length" + OpArrayOverlap FilterOperator = "_array_overlap" + OpArrayContainedBy FilterOperator = "_array_contained_by" ) // DynamicFilter represents a single filter condition @@ -627,6 +629,19 @@ func (qb *QueryBuilder) buildCTEClause(ctes []CTE) (string, []interface{}, error // buildFromClause builds the FROM clause with optional alias func (qb *QueryBuilder) buildFromClause(table, alias string) string { + // Check if the table name contains a dot (schema.table) + if strings.Contains(table, ".") { + parts := strings.Split(table, ".") + if len(parts) == 2 { + // Quote schema and table separately + fromClause := fmt.Sprintf("%s.%s", qb.escapeIdentifier(parts[0]), qb.escapeIdentifier(parts[1])) + if alias != "" { + fromClause += " " + qb.escapeIdentifier(alias) + } + return fromClause + } + } + fromClause := qb.escapeIdentifier(table) if alias != "" { fromClause += " " + qb.escapeIdentifier(alias) @@ -641,7 +656,18 @@ func (qb *QueryBuilder) buildSingleJoinClause(join Join) (string, string, string joinType = "INNER" } - table := qb.escapeIdentifier(join.Table) + var table string + if strings.Contains(join.Table, ".") { + parts := strings.Split(join.Table, ".") + if len(parts) == 2 { + // Quote schema and table separately + table = fmt.Sprintf("%s.%s", qb.escapeIdentifier(parts[0]), qb.escapeIdentifier(parts[1])) + } else { + table = qb.escapeIdentifier(join.Table) + } + } else { + table = qb.escapeIdentifier(join.Table) + } if join.Alias != "" { table += " " + qb.escapeIdentifier(join.Alias) } @@ -803,7 +829,7 @@ func (qb *QueryBuilder) buildFilterCondition(filter DynamicFilter) (string, []in switch filter.Operator { case OpJsonContains, OpJsonNotContains, OpJsonExists, OpJsonNotExists, OpJsonEqual, OpJsonNotEqual: return qb.buildJsonFilterCondition(filter) - case OpArrayContains, OpArrayNotContains, OpArrayLength: + case OpArrayContains, OpArrayNotContains, OpArrayLength, OpArrayOverlap, OpArrayContainedBy: return qb.buildArrayFilterCondition(filter) } @@ -1051,6 +1077,45 @@ func (qb *QueryBuilder) buildArrayFilterCondition(filter DynamicFilter) (string, default: return "", nil, fmt.Errorf("Array operations not supported for database type: %s", qb.dbType) } + case OpArrayOverlap: + // TAMBAHKAN INI + switch qb.dbType { + case DBTypePostgreSQL: + expr = fmt.Sprintf("%s && ?", column) + args = append(args, filter.Value) + case DBTypeMySQL: + // MySQL doesn't have native array overlap, use JSON_OVERLAPS if available + expr = fmt.Sprintf("JSON_OVERLAPS(%s, ?)", column) + args = append(args, filter.Value) + case DBTypeSQLServer: + // SQL Server workaround using EXISTS and OPENJSON + expr = fmt.Sprintf("EXISTS (SELECT 1 FROM OPENJSON(%s) o1 CROSS JOIN OPENJSON(?) o2 WHERE o1.value = o2.value)", column) + args = append(args, filter.Value) + case DBTypeSQLite: + // SQLite workaround using json_each + expr = fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(%s) j1 CROSS JOIN json_each(?) j2 WHERE j1.value = j2.value)", column) + args = append(args, filter.Value) + default: + return "", nil, fmt.Errorf("Array overlap operations not supported for database type: %s", qb.dbType) + } + case OpArrayContainedBy: + // TAMBAHKAN INI + switch qb.dbType { + case DBTypePostgreSQL: + expr = fmt.Sprintf("%s <@ ?", column) + args = append(args, filter.Value) + case DBTypeMySQL: + expr = fmt.Sprintf("JSON_CONTAINS(?, %s)", column) + args = append(args, filter.Value) + case DBTypeSQLServer: + expr = fmt.Sprintf("NOT EXISTS (SELECT 1 FROM OPENJSON(%s) WHERE value NOT IN (SELECT value FROM OPENJSON(?)))", column) + args = append(args, filter.Value) + case DBTypeSQLite: + expr = fmt.Sprintf("NOT EXISTS (SELECT 1 FROM json_each(%s) j1 WHERE j1.value NOT IN (SELECT j2.value FROM json_each(?) j2))", column) + args = append(args, filter.Value) + default: + return "", nil, fmt.Errorf("Array contained_by operations not supported for database type: %s", qb.dbType) + } case OpArrayNotContains: switch qb.dbType { case DBTypePostgreSQL: @@ -1114,11 +1179,54 @@ func (qb *QueryBuilder) buildArrayFilterCondition(filter DynamicFilter) (string, // ============================================================================= func (qb *QueryBuilder) ExecuteQuery(ctx context.Context, db *sqlx.DB, query DynamicQuery, dest interface{}) error { + // sql, args, err := qb.BuildQuery(query) + // if err != nil { + // return err + // } + // start := time.Now() + // err = db.SelectContext(ctx, dest, sql, args...) + // fmt.Printf("[DEBUG] Query executed in %v\n", time.Since(start)) + // return err sql, args, err := qb.BuildQuery(query) if err != nil { return err } start := time.Now() + + // Check if dest is a pointer to a slice of maps + destValue := reflect.ValueOf(dest) + if destValue.Kind() != reflect.Ptr || destValue.IsNil() { + return fmt.Errorf("dest must be a non-nil pointer") + } + + destElem := destValue.Elem() + if destElem.Kind() == reflect.Slice { + sliceType := destElem.Type().Elem() + if sliceType.Kind() == reflect.Map && + sliceType.Key().Kind() == reflect.String && + sliceType.Elem().Kind() == reflect.Interface { + + // Handle slice of map[string]interface{} + rows, err := db.QueryxContext(ctx, sql, args...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + row := make(map[string]interface{}) + if err := rows.MapScan(row); err != nil { + return err + } + destElem.Set(reflect.Append(destElem, reflect.ValueOf(row))) + } + + fmt.Printf("[DEBUG] Query executed in %v\n", time.Since(start)) + return nil + } + } + + // Default case: use SelectContext err = db.SelectContext(ctx, dest, sql, args...) fmt.Printf("[DEBUG] Query executed in %v\n", time.Since(start)) return err @@ -2345,3 +2453,42 @@ func (mqb *MongoQueryBuilder) ExecuteDelete(ctx context.Context, collection *mon fmt.Printf("[DEBUG] MongoDB Delete executed in %v\n", time.Since(start)) return result, err } + +func (qb *QueryBuilder) getOperatorSQL(op FilterOperator) (string, error) { + switch op { + case OpEqual: + return "=", nil + case OpNotEqual: + return "!=", nil + case OpLike: + return "LIKE", nil + case OpILike: + return "ILIKE", nil + case OpIn: + return "IN", nil + case OpNotIn: + return "NOT IN", nil + case OpGreaterThan: + return ">", nil + case OpGreaterThanEqual: + return ">=", nil + case OpLessThan: + return "<", nil + case OpLessThanEqual: + return "<=", nil + case OpNull: + return "IS NULL", nil + case OpNotNull: + return "IS NOT NULL", nil + case OpArrayContains: + return "@>", nil + case OpArrayNotContains: + return "NOT @>", nil + case OpArrayOverlap: // TAMBAHKAN INI + return "&&", nil + case OpArrayContainedBy: // BONUS + return "<@", nil + default: + return "", fmt.Errorf("unsupported operator: %s", op) + } +} diff --git a/proto/pasien.proto b/proto/pasien.proto new file mode 100644 index 0000000..b494578 --- /dev/null +++ b/proto/pasien.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package pasien; + +// module = api-service -> internal/grpc/pb +option go_package = "api-service/internal/grpc/pb;pb"; + +service PasienService { + rpc GetPasienByNomr (GetPasienByNomrRequest) returns (Pasien); +} + +message GetPasienByNomrRequest { + string nomr = 1; +} + +message Pasien { + string nomr = 1; + string nama = 2; + int32 umur = 3; +}