Compare commits

...
Author SHA1 Message Date
Efril d0378b5ac4 update category 2026-05-21 23:05:25 +07:00
Efril 91960f0e57 categories add outlet id 2026-05-21 21:27:57 +07:00
Efril 72f67cb519 create or update product assign to product outlet 2026-05-21 21:20:54 +07:00
aefril 35c4cf2f2f Merge pull request 'add purchasing in analytics endpoint' (#11) from feature/purchasing into main
Reviewed-on: #11
2026-05-19 15:53:59 +00:00
aefril c9ef90f5ea Merge pull request 'feature/outlet-table' (#10) from feature/outlet-table into main
Reviewed-on: #10
2026-05-19 15:53:16 +00:00
ryan 35e7152abb add purchasing in analytics endpoint 2026-05-19 14:45:26 +07:00
Efril d9b51a7616 update ordedr list 2026-05-14 16:17:28 +07:00
ryan b27e40b531 fix filter order by outlet id 2026-05-14 15:57:46 +07:00
ryan 44aca7641f Revert "add filter order by outlet id"
This reverts commit a89ff00d94.
2026-05-14 15:28:45 +07:00
ryan a89ff00d94 add filter order by outlet id 2026-05-14 15:15:32 +07:00
ryan 227f11359c Revert "add list order by outlet id"
This reverts commit 7a737d7f83.
2026-05-14 14:46:32 +07:00
ryan 7a737d7f83 add list order by outlet id 2026-05-14 14:41:50 +07:00
ryan 312ea94e62 fix scheduler counting void and refund 2026-05-14 14:22:07 +07:00
aefril 6d735c20cb Merge pull request 'fix pointer' (#9) from feature/outlet-table into main
Reviewed-on: #9
2026-05-14 06:54:51 +00:00
Efril cb8a830345 fix pointer 2026-05-14 13:54:15 +07:00
aefril 9c143a43aa Merge pull request 'table and order grouping by outlet' (#8) from feature/outlet-table into main
Reviewed-on: #8
2026-05-13 18:40:34 +00:00
Efril 222cadd8df table and order grouping by outlet 2026-05-14 01:40:17 +07:00
aefril cad4e6c816 Merge pull request 'feature/outlet-table' (#7) from feature/outlet-table into main
Reviewed-on: #7
2026-05-13 18:22:44 +00:00
Efril 50d633ee3a fix products 2026-05-14 01:19:45 +07:00
Efril 21fa21d089 get products all 2026-05-14 00:15:28 +07:00
ryan 5f379faf17 change product list to retrieve its data from product outlets 2026-05-13 23:15:09 +07:00
ryan 3b62504798 fix 2026-05-13 22:30:55 +07:00
ryan 4130cb66df refactor and add outlet product table 2026-05-13 21:58:54 +07:00
aefril 30dff17272 Merge pull request 'self-order+notification' (#6) from self-order+notification into main
Reviewed-on: #6
2026-05-13 07:27:05 +00:00
efrilm fa037b4d2a fix request outlet id at analytic 2026-05-13 14:23:27 +07:00
ryan d38a770ec5 Add omset milestone scheduler with owner role and revenue tracking 2026-05-13 09:48:17 +07:00
ryan 015292e830 Refactor: extract outlet ID filtering to helper method 2026-05-12 21:50:53 +07:00
efrilm f8c732f0ff update dockerfile 2026-05-12 18:46:12 +07:00
aefril e92c487815 Merge pull request 'self-order+notification' (#5) from self-order+notification into main
Reviewed-on: #5
2026-05-12 11:41:03 +00:00
ryan c573b23d76 Add outlet_id to use context or request 2026-05-12 18:32:36 +07:00
Efril f73a5d533c add notif at create order 2026-05-10 23:36:22 +07:00
Efril 4ea8e32a8e Merge branch 'self-order+notification' of https://gits.altru.id/apksel-dev/apskel-pos-backend into self-order+notification 2026-05-10 23:10:03 +07:00
Efril 06d79046d0 fix order and self order response 2026-05-10 23:07:57 +07:00
ryan 8eb19c57ba Change self-order response 2026-05-10 21:34:45 +07:00
Efril f123de7233 Revert "change order response at self order"
This reverts commit 7ba776555e.
2026-05-10 21:31:17 +07:00
Efril 7ba776555e change order response at self order 2026-05-10 21:19:37 +07:00
Efril bccf02b5f7 rename session_id 2026-05-10 19:56:34 +07:00
Efril c24a8a8c13 rename organization_id, add customer_name and order_type at order self order 2026-05-10 19:15:50 +07:00
ryan 6064ef8fde Update QR token generation 2026-05-10 14:52:02 +07:00
Efril 1834dd0b19 update url qrcode table 2026-05-10 14:13:20 +07:00
Efril 9f653eef37 fix migration number 2026-05-10 13:30:40 +07:00
Efril ddaf6df436 migration notification 2026-05-10 12:35:44 +07:00
ryan 0708ce816e Update Redis host 2026-05-10 12:34:36 +07:00
ryan 2c34578a98 Merge remote-tracking branch 'origin/feature/notification' into self-order+notification
# Conflicts:
#	go.mod
#	go.sum
#	internal/app/app.go
#	internal/router/router.go
2026-05-10 12:23:16 +07:00
Efril 9d71b339b5 notification 2026-05-10 10:57:38 +07:00
Efril bbd6666299 user devices 2026-05-10 10:42:09 +07:00
Efril 23b6293502 fcm setup 2026-05-09 12:24:44 +07:00
ryan 07b186c986 Barcode generation with Boombuler 2026-05-09 01:01:25 +07:00
ryan 4cc563f6f1 Add self-order/orders/:sessionId 2026-05-09 00:12:00 +07:00
ryan e7c4681102 Add outlet_id to self-order/categories 2026-05-08 23:56:06 +07:00
ryan f957b07d23 Change self-order/menu from POST to GET 2026-05-08 23:18:52 +07:00
ryan 3c103b7692 Token and session implementation with Redis 2026-05-08 18:41:14 +07:00
ryan fe57aab3b4 Fix categories with table 2026-05-08 14:19:40 +07:00
ryan 3721fb3cd7 List categories for self-order 2026-05-06 11:56:11 +07:00
ryan 2d6df8e4c6 Self Order - BE 2026-05-05 21:07:03 +07:00
aefril 2c76962959 Merge pull request 'ingredient composition' (#2) from feature/ingredient-composition into main
Reviewed-on: #2
2026-04-27 14:22:22 +00:00
118 changed files with 6914 additions and 264 deletions
+3
View File
@@ -6,3 +6,6 @@ config/env/*
!.env
vendor
# Firebase service account credentials
infra/firebase-service-account.json
+1 -1
View File
@@ -1,5 +1,5 @@
# 1) Build stage
FROM golang:1.21-alpine AS build
FROM golang:1.24-alpine AS build
RUN apk --no-cache add ca-certificates tzdata git curl
WORKDIR /src
COPY go.mod go.sum ./
+7 -2
View File
@@ -12,13 +12,18 @@ func main() {
cfg := config.LoadConfig()
logger.Setup(cfg.LogLevel(), cfg.LogFormat())
db, err := db.NewPostgres(cfg.Database)
pg, err := db.NewPostgres(cfg.Database)
if err != nil {
log.Fatal(err)
}
redisClient, err := db.NewRedisClient(cfg.Redis)
if err != nil {
log.Fatal(err)
}
logger.NonContext.Info("helloworld")
application := app.NewApp(db)
application := app.NewApp(pg, redisClient)
if err := application.Initialize(cfg); err != nil {
log.Fatalf("Failed to initialize application: %v", err)
+6
View File
@@ -26,10 +26,12 @@ var (
type Config struct {
Server Server `mapstructure:"server"`
Database Database `mapstructure:"postgresql"`
Redis Redis `mapstructure:"redis"`
Jwt Jwt `mapstructure:"jwt"`
Log Log `mapstructure:"log"`
S3Config S3Config `mapstructure:"s3"`
Fonnte Fonnte `mapstructure:"fonnte"`
FCM FCM `mapstructure:"fcm"`
}
var (
@@ -94,3 +96,7 @@ func (c *Config) LogFormat() string {
func (c *Config) GetFonnte() *Fonnte {
return &c.Fonnte
}
func (c *Config) GetFCM() *FCM {
return &c.FCM
}
+14
View File
@@ -0,0 +1,14 @@
package config
type FCM struct {
CredentialsFile string `mapstructure:"credentials_file"`
ProjectID string `mapstructure:"project_id"`
}
func (f *FCM) GetCredentialsFile() string {
return f.CredentialsFile
}
func (f *FCM) GetProjectID() string {
return f.ProjectID
}
+55
View File
@@ -0,0 +1,55 @@
package config
import (
"fmt"
"time"
)
type Redis struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
DialTimeout string `mapstructure:"dial_timeout"`
ReadTimeout string `mapstructure:"read_timeout"`
WriteTimeout string `mapstructure:"write_timeout"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConnections int `mapstructure:"min_idle_connections"`
}
func (r Redis) Addr() string {
return fmt.Sprintf("%s:%d", r.Host, r.Port)
}
func (r Redis) ParseDialTimeout() time.Duration {
if r.DialTimeout == "" {
return 5 * time.Second
}
d, err := time.ParseDuration(r.DialTimeout)
if err != nil {
return 5 * time.Second
}
return d
}
func (r Redis) ParseReadTimeout() time.Duration {
if r.ReadTimeout == "" {
return 3 * time.Second
}
d, err := time.ParseDuration(r.ReadTimeout)
if err != nil {
return 3 * time.Second
}
return d
}
func (r Redis) ParseWriteTimeout() time.Duration {
if r.WriteTimeout == "" {
return 3 * time.Second
}
d, err := time.ParseDuration(r.WriteTimeout)
if err != nil {
return 3 * time.Second
}
return d
}
+1
View File
@@ -4,4 +4,5 @@ type Server struct {
Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"`
SelfOrderUrl string `mapstructure:"self-order-url"`
}
+63 -14
View File
@@ -1,28 +1,54 @@
module apskel-pos-be
go 1.21
go 1.24
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.17.0
github.com/google/uuid v1.1.2
github.com/google/uuid v1.6.0
github.com/lib/pq v1.2.0
github.com/spf13/viper v1.16.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cel.dev/expr v0.23.1 // indirect
cloud.google.com/go v0.121.0 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.53.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -31,7 +57,7 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -39,34 +65,57 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.10.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
firebase.google.com/go/v4 v4.19.0
github.com/aws/aws-sdk-go v1.55.7
github.com/boombuler/barcode v1.1.0
github.com/golang-jwt/jwt/v5 v5.2.3
github.com/redis/go-redis/v9 v9.19.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.10.0
go.uber.org/zap v1.21.0
golang.org/x/crypto v0.28.0
golang.org/x/crypto v0.40.0
google.golang.org/api v0.231.0
gorm.io/driver/postgres v1.5.0
gorm.io/gorm v1.30.0
)
+156 -23
View File
@@ -1,3 +1,5 @@
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -17,14 +19,32 @@ cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHOb
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY=
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc=
cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -35,18 +55,42 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
cloud.google.com/go/trace v1.11.6 h1:2O2zjPzqPYAHrn3OKl029qlqG6W8ZdYaOWRyr8NgMT4=
cloud.google.com/go/trace v1.11.6/go.mod h1:GA855OeDEBiBMzcckLPE2kDunIpC72N+Pq8WFieFjnI=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=
firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0 h1:OqVGm6Ei3x5+yZmSJG1Mh2NwHvpVmZ08CB5qJhT9Nuk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.51.0/go.mod h1:SZiPHWGOOk3bl8tkevxkoiwPgsIl6CwrWcbwjfHZpdM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
@@ -61,6 +105,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -70,7 +116,17 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
@@ -84,6 +140,13 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -94,6 +157,9 @@ github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -121,6 +187,9 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -132,12 +201,16 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -149,10 +222,17 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -181,8 +261,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -215,17 +295,21 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
github.com/spf13/afero v1.10.0/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
@@ -234,10 +318,13 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -247,8 +334,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -261,17 +349,41 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
@@ -289,8 +401,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -361,8 +473,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -372,6 +484,8 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -385,6 +499,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -428,8 +544,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -441,12 +557,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -519,6 +638,8 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q=
google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -526,6 +647,8 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -562,6 +685,12 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -578,6 +707,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -588,8 +719,10 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+16
View File
@@ -1,6 +1,7 @@
server:
base-url:
local-url:
self-order-url: http://localhost:5173
port: 4000
jwt:
@@ -27,6 +28,17 @@ postgresql:
connection-max-life-time-in-second: 600
debug: false
redis:
host: 194.233.78.1
port: 6379
password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 0
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
pool_size: 10
min_idle_connections: 5
s3:
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
access_key_secret: 91f3321294d3e23035427a0ecb893ada
@@ -43,3 +55,7 @@ fonnte:
api_url: "https://api.fonnte.com/send"
token: "bADQrf9NTXfLZQCK2wGg"
timeout: 30
fcm:
credentials_file: "infra/firebase-service-account.json"
project_id: "apskel-pos-v2"
+99 -6
View File
@@ -20,19 +20,23 @@ import (
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/validator"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type App struct {
server *http.Server
db *gorm.DB
redisClient *redis.Client
router *router.Router
shutdown chan os.Signal
omsetScheduler *service.OmsetMilestoneScheduler
}
func NewApp(db *gorm.DB) *App {
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
return &App{
db: db,
redisClient: redisClient,
shutdown: make(chan os.Signal, 1),
}
}
@@ -40,10 +44,29 @@ func NewApp(db *gorm.DB) *App {
func (a *App) Initialize(cfg *config.Config) error {
repos := a.initRepositories()
processors := a.initProcessors(cfg, repos)
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.userRepo,
processors.notificationProcessor,
)
services := a.initServices(processors, repos, cfg)
validators := a.initValidators()
middleware := a.initMiddleware(services, cfg)
healthHandler := handler.NewHealthHandler()
selfOrderHandler := handler.NewSelfOrderHandler(
services.orderService,
services.categoryService,
services.productService,
repos.tableRepo,
repos.outletRepo,
repos.userRepo,
repos.sessionRepo,
repos.orderRepo,
services.productOutletPriceService,
)
a.router = router.NewRouter(
cfg,
@@ -105,12 +128,24 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerPointsService,
services.spinGameService,
middleware.customerAuthMiddleware,
services.userDeviceService,
validators.userDeviceValidator,
services.notificationService,
validators.notificationValidator,
services.productOutletPriceService,
validators.productOutletPriceValidator,
selfOrderHandler,
)
return nil
}
func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every hour)
if a.omsetScheduler != nil {
a.omsetScheduler.Start(1 * time.Hour)
}
engine := a.router.Init()
a.server = &http.Server{
@@ -146,6 +181,9 @@ func (a *App) Start(port string) error {
}
func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
close(a.shutdown)
}
@@ -191,7 +229,13 @@ type repositories struct {
customerAuthRepo repository.CustomerAuthRepository
customerPointsRepo repository.CustomerPointsRepository
otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository
txManager *repository.TxManager
userDeviceRepo *repository.UserDeviceRepositoryImpl
notificationRepo *repository.NotificationRepositoryImpl
notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
}
func (a *App) initRepositories() *repositories {
@@ -237,7 +281,13 @@ func (a *App) initRepositories() *repositories {
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
otpRepo: repository.NewOtpRepository(a.db),
sessionRepo: repository.NewSessionRepository(a.redisClient),
txManager: repository.NewTxManager(a.db),
userDeviceRepo: repository.NewUserDeviceRepositoryImpl(a.db),
notificationRepo: repository.NewNotificationRepository(a.db),
notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db),
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
}
}
@@ -280,6 +330,9 @@ type processors struct {
otpProcessor processor.OtpProcessor
fileClient processor.FileClient
inventoryMovementService service.InventoryMovementService
userDeviceProcessor *processor.UserDeviceProcessorImpl
notificationProcessor *processor.NotificationProcessorImpl
productOutletPriceProcessor processor.ProductOutletPriceProcessor
}
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
@@ -294,10 +347,10 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo),
outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo),
categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService),
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
@@ -327,6 +380,9 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
otpProcessor: otpProcessor,
fileClient: fileClient,
inventoryMovementService: inventoryMovementService,
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
}
}
@@ -363,11 +419,14 @@ type services struct {
customerAuthService service.CustomerAuthService
customerPointsService service.CustomerPointsService
spinGameService service.SpinGameService
userDeviceService service.UserDeviceService
notificationService service.NotificationService
productOutletPriceService service.ProductOutletPriceService
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
authConfig := cfg.Auth()
authService := service.NewAuthService(processors.userProcessor, authConfig)
authService := service.NewAuthService(processors.userProcessor, processors.userDeviceProcessor, authConfig)
organizationService := service.NewOrganizationService(processors.organizationProcessor)
outletService := service.NewOutletService(processors.outletProcessor)
outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor)
@@ -375,7 +434,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productService := service.NewProductService(processors.productProcessor)
productVariantService := service.NewProductVariantService(processors.productVariantProcessor)
inventoryService := service.NewInventoryService(processors.inventoryProcessor)
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) // Will be updated after orderIngredientTransactionService is created
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
fileService := service.NewFileServiceImpl(processors.fileProcessor)
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
@@ -398,9 +457,11 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
notificationService := service.NewNotificationService(processors.notificationProcessor)
// Update order service with order ingredient transaction service
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager)
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo)
return &services{
userService: service.NewUserService(processors.userProcessor),
@@ -435,6 +496,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService: customerAuthService,
customerPointsService: customerPointsService,
spinGameService: spinGameService,
userDeviceService: userDeviceService,
notificationService: notificationService,
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
}
}
@@ -474,6 +538,9 @@ type validators struct {
rewardValidator validator.RewardValidator
campaignValidator validator.CampaignValidator
customerAuthValidator validator.CustomerAuthValidator
userDeviceValidator *validator.UserDeviceValidatorImpl
notificationValidator *validator.NotificationValidatorImpl
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
}
func (a *App) initValidators() *validators {
@@ -501,5 +568,31 @@ func (a *App) initValidators() *validators {
rewardValidator: validator.NewRewardValidator(),
campaignValidator: validator.NewCampaignValidator(),
customerAuthValidator: validator.NewCustomerAuthValidator(),
userDeviceValidator: validator.NewUserDeviceValidator(),
notificationValidator: validator.NewNotificationValidator(),
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
}
}
// buildNotificationProcessor creates the notification processor with FCM integration.
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
var fcmClient client.FCMClient
if cfg.FCM.CredentialsFile != "" {
var err error
fcmClient, err = client.NewFCMClient(&cfg.FCM)
if err != nil {
// FCM init failure is non-fatal; notifications will still be persisted.
fcmClient = nil
}
}
return processor.NewNotificationProcessor(
repos.notificationRepo,
repos.notificationReceiverRepo,
repos.notificationDeliveryRepo,
repos.userDeviceRepo,
repos.userRepo,
fcmClient,
)
}
+142
View File
@@ -0,0 +1,142 @@
package client
import (
"context"
"fmt"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"google.golang.org/api/option"
)
type FCMConfig interface {
GetCredentialsFile() string
GetProjectID() string
}
type FCMClient interface {
SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error
}
type fcmClient struct {
messaging *messaging.Client
}
func NewFCMClient(cfg FCMConfig) (FCMClient, error) {
ctx := context.Background()
opt := option.WithCredentialsFile(cfg.GetCredentialsFile())
app, err := firebase.NewApp(ctx, &firebase.Config{
ProjectID: cfg.GetProjectID(),
}, opt)
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase app: %w", err)
}
msgClient, err := app.Messaging(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase messaging client: %w", err)
}
return &fcmClient{
messaging: msgClient,
}, nil
}
// SendNotification sends a push notification to a single device token.
func (f *fcmClient) SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Token: token,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM notification: %w", err)
}
return nil
}
// SendMulticastNotification sends a push notification to multiple device tokens.
func (f *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
message := &messaging.MulticastMessage{
Tokens: tokens,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
batchResp, err := f.messaging.SendEachForMulticast(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM multicast notification: %w", err)
}
if batchResp.FailureCount > 0 {
return fmt.Errorf("FCM multicast: %d/%d messages failed to send", batchResp.FailureCount, len(tokens))
}
return nil
}
// SendToTopic sends a push notification to all devices subscribed to a topic.
func (f *fcmClient) SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Topic: topic,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM topic notification: %w", err)
}
return nil
}
+4
View File
@@ -56,6 +56,10 @@ const (
CampaignRuleEntity = "campaign_rule"
CustomerEntity = "customer"
SpinGameHandlerEntity = "spin_game_handler"
UserDeviceServiceEntity = "user_device_service"
NotificationServiceEntity = "notification_service"
NotificationHandlerEntity = "notification_handler"
ProductOutletPriceServiceEntity = "product_outlet_price_service"
)
var HttpErrorMap = map[string]int{
+2
View File
@@ -7,6 +7,7 @@ const (
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
)
func GetAllUserRoles() []UserRole {
@@ -15,6 +16,7 @@ func GetAllUserRoles() []UserRole {
RoleManager,
RoleCashier,
RoleWaiter,
RoleOwner,
}
}
+63 -6
View File
@@ -8,7 +8,7 @@ import (
type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
@@ -45,7 +45,7 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
@@ -83,10 +83,67 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"`
}
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
}
type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
Limit int `form:"limit,default=1000" validate:"min=1,max=1000"`
@@ -123,7 +180,7 @@ type ProductAnalyticsData struct {
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
type ProductAnalyticsPerCategoryRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -152,7 +209,7 @@ type ProductAnalyticsPerCategoryData struct {
// DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -182,7 +239,7 @@ type DashboardOverview struct {
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
+4
View File
@@ -10,6 +10,7 @@ type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -18,12 +19,14 @@ type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type ListCategoriesRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
BusinessType string `json:"business_type,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"required,min=1"`
@@ -34,6 +37,7 @@ type ListCategoriesRequest struct {
type CategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"`
Description *string `json:"description"`
BusinessType string `json:"business_type"`
@@ -0,0 +1,92 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request contracts ----
type SendNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ReceiverIDs []uuid.UUID `json:"receiver_ids" validate:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type ListNotificationsRequest struct {
Page int `form:"page" validate:"min=1"`
Limit int `form:"limit" validate:"min=1,max=100"`
IsRead *bool `form:"is_read"`
}
// ---- Response contracts ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int64 `json:"total_count"`
UnreadCount int64 `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+9 -5
View File
@@ -8,6 +8,7 @@ import (
type CreateProductRequest struct {
CategoryID uuid.UUID `json:"category_id" validate:"required"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
@@ -19,12 +20,13 @@ type CreateProductRequest struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
CreateInventory bool `json:"create_inventory,omitempty"`
}
type UpdateProductRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
@@ -36,8 +38,7 @@ type UpdateProductRequest struct {
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
// Stock management fields
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
}
type CreateProductVariantRequest struct {
@@ -64,6 +65,8 @@ type ProductResponse struct {
Name string `json:"name"`
Description *string `json:"description"`
Price float64 `json:"price"`
OutletPrice *float64 `json:"outlet_price,omitempty"`
OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"`
Cost float64 `json:"cost"`
BusinessType string `json:"business_type"`
ImageURL *string `json:"image_url"`
@@ -89,6 +92,7 @@ type ProductVariantResponse struct {
type ListProductsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
BusinessType string `json:"business_type,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -0,0 +1,42 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
}
type UpdateProductOutletPriceRequest struct {
Price float64 `json:"price" validate:"required,min=0"`
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id,omitempty"`
ProductID uuid.UUID `json:"product_id,omitempty"`
OutletID uuid.UUID `json:"outlet_id"`
OutletName string `json:"outlet_name,omitempty"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type ListProductOutletPricesResponse struct {
Prices []ProductOutletPriceResponse `json:"prices"`
TotalCount int `json:"total_count"`
}
type BulkCreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
Prices []CreateProductOutletPricePerOutletRequest `json:"prices" validate:"required,dive"`
}
type CreateProductOutletPricePerOutletRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
}
+82
View File
@@ -0,0 +1,82 @@
package contract
import (
"github.com/google/uuid"
)
type SelfOrderTableTokenResponse struct {
SessionID string `json:"session_id"`
TableID string `json:"table_id"`
OrganizationID string `json:"organization_id"`
OutletID string `json:"outlet_id"`
TableName string `json:"table_name"`
OutletName string `json:"outlet_name"`
Status string `json:"status"`
}
type SelfOrderMenuRequest struct {
SessionID string `form:"session_id" validate:"required"`
}
type SelfOrderMenuResponse struct {
OutletName string `json:"outlet_name"`
TableName string `json:"table_name"`
Categories []SelfOrderMenuCategory `json:"categories"`
}
type SelfOrderMenuCategory struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Order int `json:"order"`
Products []SelfOrderMenuItem `json:"products"`
}
type SelfOrderMenuItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Price float64 `json:"price"`
ImageURL *string `json:"image_url,omitempty"`
Variants []SelfOrderMenuVariant `json:"variants,omitempty"`
}
type SelfOrderMenuVariant struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
PriceModifier float64 `json:"price_modifier"`
}
type SelfOrderCreateOrderRequest struct {
SessionID string `json:"session_id" validate:"required"`
CustomerName string `json:"customer_name" validate:"required"`
OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"`
OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"`
}
type SelfOrderCreateOrderItem struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
Quantity int `json:"quantity" validate:"required,min=1"`
Notes *string `json:"notes,omitempty"`
}
type SelfOrderListCategoriesRequest struct {
OrganizationID string `form:"organization_id" validate:"required"`
OutletID string `form:"outlet_id" validate:"required"`
}
type SelfOrderCategoryItem struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Order int `json:"order"`
}
type SelfOrderListCategoriesResponse struct {
Categories []SelfOrderCategoryItem `json:"categories"`
}
type SelfOrderListOrdersResponse struct {
Orders []OrderResponse `json:"orders"`
}
+7
View File
@@ -37,6 +37,13 @@ type UpdateUserOutletRequest struct {
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"`
Platform string `json:"platform,omitempty"`
FCMToken string `json:"fcm_token,omitempty"`
AppVersion string `json:"app_version,omitempty"`
OsVersion string `json:"os_version,omitempty"`
}
type LoginResponse struct {
+59
View File
@@ -0,0 +1,59 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type RegisterUserDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=1,max=255"`
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListUserDevicesRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+30
View File
@@ -0,0 +1,30 @@
package db
import (
"apskel-pos-be/config"
"fmt"
"github.com/redis/go-redis/v9"
)
func NewRedisClient(c config.Redis) (*redis.Client, error) {
opts := &redis.Options{
Addr: c.Addr(),
Password: c.Password,
DB: c.DB,
DialTimeout: c.ParseDialTimeout(),
ReadTimeout: c.ParseReadTimeout(),
WriteTimeout: c.ParseWriteTimeout(),
}
if c.PoolSize > 0 {
opts.PoolSize = c.PoolSize
}
if c.MinIdleConnections > 0 {
opts.MinIdleConns = c.MinIdleConnections
}
client := redis.NewClient(opts)
fmt.Println("Successfully connected to Redis")
return client, nil
}
+45
View File
@@ -27,6 +27,51 @@ type SalesAnalytics struct {
NetSales float64 `json:"net_sales"`
}
// PurchasingAnalytics represents purchasing analytics data
type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
type ProductAnalytics struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
+1
View File
@@ -33,6 +33,7 @@ func (m *Metadata) Scan(value interface{}) error {
type Category struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Description *string `gorm:"type:text" json:"description"`
Order int `gorm:"default:0" json:"order"`
+6
View File
@@ -36,6 +36,12 @@ func GetAllEntities() []interface{} {
&CampaignRule{},
&OtpSession{},
// Analytics entities are not database tables, they are query results
&UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
&ProductOutletPrice{},
}
}
+150
View File
@@ -0,0 +1,150 @@
package entities
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationPriority string
type NotificationDeliveryStatus string
type NotificationChannel string
type NotificationProvider string
const (
NotificationPriorityLow NotificationPriority = "low"
NotificationPriorityNormal NotificationPriority = "normal"
NotificationPriorityHigh NotificationPriority = "high"
NotificationDeliveryStatusPending NotificationDeliveryStatus = "pending"
NotificationDeliveryStatusSent NotificationDeliveryStatus = "sent"
NotificationDeliveryStatusDelivered NotificationDeliveryStatus = "delivered"
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "failed"
NotificationChannelPush NotificationChannel = "push"
NotificationChannelWebsocket NotificationChannel = "websocket"
NotificationChannelEmail NotificationChannel = "email"
NotificationProviderFirebase NotificationProvider = "firebase"
)
// NotificationData is a JSON-serializable map for extra notification payload.
type NotificationData map[string]interface{}
func (d NotificationData) Value() (driver.Value, error) {
if d == nil {
return nil, nil
}
return json.Marshal(d)
}
func (d *NotificationData) Scan(value interface{}) error {
if value == nil {
*d = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
// Notification is the master notification record.
type Notification struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Title string `gorm:"not null;size:255" json:"title"`
Body string `gorm:"type:text" json:"body"`
Type string `gorm:"size:100" json:"type"`
Category string `gorm:"size:100" json:"category"`
Priority NotificationPriority `gorm:"size:50;default:'normal'" json:"priority"`
ImageURL string `gorm:"size:512" json:"image_url"`
ActionURL string `gorm:"size:512" json:"action_url"`
NotifiableType string `gorm:"size:100" json:"notifiable_type"`
NotifiableID *uuid.UUID `gorm:"type:uuid" json:"notifiable_id"`
Data NotificationData `gorm:"type:jsonb" json:"data"`
ScheduledAt *time.Time `gorm:"type:timestamptz" json:"scheduled_at"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at"`
CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
Receivers []*NotificationReceiver `gorm:"foreignKey:NotificationID" json:"receivers,omitempty"`
}
func (n *Notification) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (Notification) TableName() string {
return "notifications"
}
// NotificationReceiver links a notification to a specific user.
type NotificationReceiver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
ReadAt *time.Time `gorm:"type:timestamptz" json:"read_at"`
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
DeletedAt *time.Time `gorm:"type:timestamptz" json:"deleted_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Notification *Notification `gorm:"foreignKey:NotificationID" json:"notification,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Deliveries []*NotificationDelivery `gorm:"foreignKey:NotificationReceiverID" json:"deliveries,omitempty"`
}
func (n *NotificationReceiver) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationReceiver) TableName() string {
return "notification_receivers"
}
// NotificationDelivery tracks per-device delivery attempts.
type NotificationDelivery struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationReceiverID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_receiver_id"`
UserDeviceID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_device_id"`
Channel NotificationChannel `gorm:"size:50;default:'push'" json:"channel"`
DeliveryStatus NotificationDeliveryStatus `gorm:"size:50;default:'pending'" json:"delivery_status"`
Provider NotificationProvider `gorm:"size:50" json:"provider"`
ProviderMessageID string `gorm:"size:255" json:"provider_message_id"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
DeliveredAt *time.Time `gorm:"type:timestamptz" json:"delivered_at"`
FailedAt *time.Time `gorm:"type:timestamptz" json:"failed_at"`
FailureReason string `gorm:"type:text" json:"failure_reason"`
RetryCount int `gorm:"default:0" json:"retry_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
NotificationReceiver *NotificationReceiver `gorm:"foreignKey:NotificationReceiverID" json:"notification_receiver,omitempty"`
UserDevice *UserDevice `gorm:"foreignKey:UserDeviceID" json:"user_device,omitempty"`
}
func (n *NotificationDelivery) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationDelivery) TableName() string {
return "notification_deliveries"
}
+31
View File
@@ -0,0 +1,31 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ProductOutletPrice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
}
func (p *ProductOutletPrice) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (ProductOutletPrice) TableName() string {
return "product_outlet_prices"
}
+5
View File
@@ -1,6 +1,7 @@
package entities
import (
"apskel-pos-be/internal/pkg/tabletoken"
"time"
"github.com/google/uuid"
@@ -12,6 +13,7 @@ type Table struct {
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
TableName string `gorm:"not null;size:100" json:"table_name" validate:"required"`
Token string `gorm:"uniqueIndex;not null;size:255" json:"token"`
StartTime *time.Time `gorm:"" json:"start_time"`
Status string `gorm:"default:'available';size:50" json:"status"`
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
@@ -33,6 +35,9 @@ func (t *Table) BeforeCreate(tx *gorm.DB) error {
if t.ID == uuid.Nil {
t.ID = uuid.New()
}
if t.Token == "" {
t.Token = tabletoken.Encode(t.ID, t.OrganizationID, t.OutletID)
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DeviceType string
type DevicePlatform string
const (
DeviceTypeMobile DeviceType = "mobile"
DeviceTypeTablet DeviceType = "tablet"
DeviceTypeDesktop DeviceType = "desktop"
DevicePlatformAndroid DevicePlatform = "android"
DevicePlatformIOS DevicePlatform = "ios"
DevicePlatformWeb DevicePlatform = "web"
)
type UserDevice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
DeviceID string `gorm:"not null;size:255;index" json:"device_id"`
DeviceName string `gorm:"size:255" json:"device_name"`
DeviceType DeviceType `gorm:"size:50" json:"device_type"`
Platform DevicePlatform `gorm:"size:50" json:"platform"`
FCMToken string `gorm:"size:512" json:"fcm_token"`
AppVersion string `gorm:"size:50" json:"app_version"`
OsVersion string `gorm:"size:50" json:"os_version"`
IPAddress string `gorm:"size:45" json:"ip_address"`
LastActiveAt *time.Time `gorm:"type:timestamptz" json:"last_active_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (u *UserDevice) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
func (UserDevice) TableName() string {
return "user_devices"
}
+42 -5
View File
@@ -8,6 +8,7 @@ import (
"apskel-pos-be/internal/util"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AnalyticsHandler struct {
@@ -25,6 +26,17 @@ func NewAnalyticsHandler(
}
}
func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
return &outletIDStr
}
if contextOutletID != uuid.Nil {
s := contextOutletID.String()
return &s
}
return nil
}
func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -36,7 +48,7 @@ func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq)
@@ -60,7 +72,7 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.SalesAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq)
@@ -73,6 +85,30 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
}
func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.PurchasingAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PurchasingAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
contractResp := transformer.PurchasingAnalyticsModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics")
}
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -84,7 +120,7 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ProductAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq)
@@ -108,7 +144,7 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq)
@@ -132,7 +168,7 @@ func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = &contextInfo.OutletID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.DashboardAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq)
@@ -156,6 +192,7 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ProfitLossAnalyticsContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProfitLossAnalytics", err.Error())}), "AnalyticsHandler::GetProfitLossAnalytics")
+15
View File
@@ -44,6 +44,11 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
return
}
// Inject outlet_id from context if user has one and request doesn't provide it
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
@@ -149,6 +154,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
OrganizationID: &contextInfo.OrganizationID,
}
// Inject outlet_id from context if user has one
if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
// Parse query parameters
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
@@ -176,6 +186,11 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
+190
View File
@@ -0,0 +1,190 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type NotificationHandler struct {
notificationService service.NotificationService
notificationValidator validator.NotificationValidator
}
func NewNotificationHandler(
notificationService service.NotificationService,
notificationValidator validator.NotificationValidator,
) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
notificationValidator: notificationValidator,
}
}
// Send godoc
// POST /api/v1/notifications/send
// Sends a notification to specific users.
func (h *NotificationHandler) Send(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.SendNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Send -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Send")
return
}
if validationErr, errCode := h.notificationValidator.ValidateSendRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Send")
return
}
resp := h.notificationService.Send(ctx, &req, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Send -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Send")
}
// Broadcast godoc
// POST /api/v1/notifications/broadcast
// Sends a notification to all active users in the caller's organization.
func (h *NotificationHandler) Broadcast(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.BroadcastNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Broadcast -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Broadcast")
return
}
if validationErr, errCode := h.notificationValidator.ValidateBroadcastRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Broadcast")
return
}
resp := h.notificationService.Broadcast(ctx, &req, contextInfo.OrganizationID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Broadcast -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Broadcast")
}
// List godoc
// GET /api/v1/notifications
// Returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := contract.ListNotificationsRequest{
Page: 1,
Limit: 20,
}
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::List -> query binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::List")
return
}
if validationErr, errCode := h.notificationValidator.ValidateListRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::List")
return
}
resp := h.notificationService.ListForUser(ctx, &req, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::List")
}
// GetByID godoc
// GET /api/v1/notifications/:id
func (h *NotificationHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::GetByID -> invalid notification ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::GetByID")
return
}
resp := h.notificationService.GetByID(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::GetByID")
}
// MarkAsRead godoc
// PUT /api/v1/notifications/:id/read
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::MarkAsRead -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::MarkAsRead")
return
}
resp := h.notificationService.MarkAsRead(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::MarkAsRead -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAsRead")
}
// MarkAllAsRead godoc
// PUT /api/v1/notifications/read-all
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
resp := h.notificationService.MarkAllAsRead(ctx, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAllAsRead")
}
// Delete godoc
// DELETE /api/v1/notifications/:id
func (h *NotificationHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Delete -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Delete")
return
}
resp := h.notificationService.DeleteForUser(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Delete -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Delete")
}
+4
View File
@@ -137,6 +137,10 @@ func (h *OrderHandler) ListOrders(c *gin.Context) {
}
modelReq.OrganizationID = &contextInfo.OrganizationID
if modelReq.OutletID == nil && contextInfo.OutletID != uuid.Nil {
modelReq.OutletID = &contextInfo.OutletID
}
response, err := h.orderService.ListOrders(c.Request.Context(), modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders")
+95 -2
View File
@@ -60,6 +60,7 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -85,7 +86,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
return
}
productResponse := h.productService.UpdateProduct(ctx, productID, &req)
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
@@ -117,6 +118,7 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) {
func (h *ProductHandler) GetProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -127,7 +129,7 @@ func (h *ProductHandler) GetProduct(c *gin.Context) {
return
}
productResponse := h.productService.GetProductByID(ctx, productID)
productResponse := h.productService.GetProductByID(ctx, productID, contextInfo.OutletID)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service")
@@ -184,6 +186,97 @@ func (h *ProductHandler) ListProducts(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
} else if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
req.MaxPrice = &maxPrice
}
}
validationError, validationErrorCode := h.productValidator.ValidateListProductsRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("ProductHandler::ListProducts -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductHandler::ListProducts")
return
}
productsResponse := h.productService.ListProducts(ctx, req)
if productsResponse.HasErrors() {
errorResp := productsResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::ListProducts -> Failed to list products from service")
}
util.HandleResponse(c.Writer, c.Request, productsResponse, "ProductHandler::ListProducts")
}
func (h *ProductHandler) ListProductAll(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListProductsRequest{
Page: 1,
Limit: 10,
OrganizationID: &contextInfo.OrganizationID,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if businessType := c.Query("business_type"); businessType != "" {
req.BusinessType = businessType
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID
}
}
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
if categoryID, err := uuid.Parse(categoryIDStr); err == nil {
req.CategoryID = &categoryID
}
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
@@ -0,0 +1,135 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ProductOutletPriceHandler struct {
service service.ProductOutletPriceService
validator validator.ProductOutletPriceValidator
}
func NewProductOutletPriceHandler(svc service.ProductOutletPriceService, v validator.ProductOutletPriceValidator) *ProductOutletPriceHandler {
return &ProductOutletPriceHandler{
service: svc,
validator: v,
}
}
func (h *ProductOutletPriceHandler) Upsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::Upsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
if validationErr, code := h.validator.ValidateCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
resp := h.service.Upsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Upsert")
}
func (h *ProductOutletPriceHandler) GetByProductAndOutlet(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
resp := h.service.GetByProductAndOutlet(ctx, productID, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProductAndOutlet")
}
func (h *ProductOutletPriceHandler) GetByProduct(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProduct")
return
}
resp := h.service.GetByProduct(ctx, productID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProduct")
}
func (h *ProductOutletPriceHandler) GetByOutlet(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByOutlet")
return
}
resp := h.service.GetByOutlet(ctx, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByOutlet")
}
func (h *ProductOutletPriceHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Delete")
return
}
resp := h.service.Delete(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Delete")
}
func (h *ProductOutletPriceHandler) BulkUpsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.BulkCreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::BulkUpsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
if validationErr, code := h.validator.ValidateBulkCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
resp := h.service.BulkUpsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::BulkUpsert")
}
+17 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ReportHandler struct {
@@ -19,11 +20,26 @@ func NewReportHandler(reportService service.ReportService, userService UserServi
return &ReportHandler{reportService: reportService, userService: userService}
}
func (h *ReportHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if _, err := uuid.Parse(outletIDStr); err == nil {
return outletIDStr
}
}
if pathOutletID := c.Param("outlet_id"); pathOutletID != "" {
return pathOutletID
}
if contextOutletID != uuid.Nil {
return contextOutletID.String()
}
return ""
}
func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := c.Param("outlet_id")
outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
+571
View File
@@ -0,0 +1,571 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/transformer"
"apskel-pos-be/internal/util"
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type SelfOrderHandler struct {
orderService service.OrderService
categoryService service.CategoryService
productService service.ProductService
tableRepo repository.TableRepositoryInterface
outletRepo processor.OutletRepository
userRepo processor.UserRepository
sessionRepo repository.SessionRepository
orderRepo repository.OrderRepository
productOutletPriceService service.ProductOutletPriceService
}
func NewSelfOrderHandler(
orderService service.OrderService,
categoryService service.CategoryService,
productService service.ProductService,
tableRepo repository.TableRepositoryInterface,
outletRepo processor.OutletRepository,
userRepo processor.UserRepository,
sessionRepo repository.SessionRepository,
orderRepo repository.OrderRepository,
productOutletPriceService service.ProductOutletPriceService,
) *SelfOrderHandler {
return &SelfOrderHandler{
orderService: orderService,
categoryService: categoryService,
productService: productService,
tableRepo: tableRepo,
outletRepo: outletRepo,
userRepo: userRepo,
sessionRepo: sessionRepo,
orderRepo: orderRepo,
productOutletPriceService: productOutletPriceService,
}
}
func (h *SelfOrderHandler) ValidateToken(c *gin.Context) {
ctx := c.Request.Context()
token := c.Param("token")
if token == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "token is required"),
}), "SelfOrderHandler::ValidateToken")
return
}
tableID, orgID, outletID, err := tabletoken.Decode(token)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ValidateToken -> invalid token")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid table token"),
}), "SelfOrderHandler::ValidateToken")
return
}
table, err := h.tableRepo.GetByID(ctx, tableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ValidateToken -> table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"),
}), "SelfOrderHandler::ValidateToken")
return
}
if !table.IsActive {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not active"),
}), "SelfOrderHandler::ValidateToken")
return
}
if table.OrganizationID != orgID || table.OutletID != outletID {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "token does not match table"),
}), "SelfOrderHandler::ValidateToken")
return
}
outlet, err := h.outletRepo.GetByID(ctx, table.OutletID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ValidateToken -> outlet not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.OrderServiceEntity, "outlet not found"),
}), "SelfOrderHandler::ValidateToken")
return
}
existingSession, err := h.sessionRepo.GetActiveByTableID(ctx, table.ID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ValidateToken -> failed to check session")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to check session"),
}), "SelfOrderHandler::ValidateToken")
return
}
var sessionStatus string
var sessionID string
if existingSession != nil {
sessionStatus = "joined_session"
sessionID = existingSession.ID
} else {
session := &models.SelfOrderSession{
TableID: table.ID,
OrganizationID: table.OrganizationID,
OutletID: table.OutletID,
}
if err := h.sessionRepo.Create(ctx, session); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ValidateToken -> failed to create session")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to create session"),
}), "SelfOrderHandler::ValidateToken")
return
}
sessionStatus = "new_session"
sessionID = session.ID
}
resp := &contract.SelfOrderTableTokenResponse{
SessionID: sessionID,
TableID: table.ID.String(),
OrganizationID: table.OrganizationID.String(),
OutletID: table.OutletID.String(),
TableName: table.TableName,
OutletName: outlet.Name,
Status: sessionStatus,
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::ValidateToken")
}
func (h *SelfOrderHandler) GetMenu(c *gin.Context) {
ctx := c.Request.Context()
var req contract.SelfOrderMenuRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetMenu -> query binding failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::GetMenu")
return
}
session, table, outlet, err := h.resolveSession(ctx, req.SessionID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::GetMenu")
return
}
if session == nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found or expired"),
}), "SelfOrderHandler::GetMenu")
return
}
isActive := true
catResp := h.categoryService.ListCategories(ctx, &contract.ListCategoriesRequest{
OrganizationID: &table.OrganizationID,
Page: 1,
Limit: 100,
})
if catResp.HasErrors() {
logger.FromContext(ctx).WithError(catResp.GetErrors()[0]).Error("SelfOrderHandler::GetMenu -> failed to list categories")
util.HandleResponse(c.Writer, c.Request, catResp, "SelfOrderHandler::GetMenu")
return
}
prodResp := h.productService.ListProducts(ctx, &contract.ListProductsRequest{
OrganizationID: &table.OrganizationID,
IsActive: &isActive,
Page: 1,
Limit: 1000,
})
if prodResp.HasErrors() {
logger.FromContext(ctx).WithError(prodResp.GetErrors()[0]).Error("SelfOrderHandler::GetMenu -> failed to list products")
util.HandleResponse(c.Writer, c.Request, prodResp, "SelfOrderHandler::GetMenu")
return
}
catList, ok := catResp.Data.(*contract.ListCategoriesResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.CategoryServiceEntity, "unexpected categories response type"),
}), "SelfOrderHandler::GetMenu")
return
}
prodList, ok := prodResp.Data.(*contract.ListProductsResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductServiceEntity, "unexpected products response type"),
}), "SelfOrderHandler::GetMenu")
return
}
menu := h.buildMenuResponse(ctx, outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
}
func (h *SelfOrderHandler) buildMenuResponse(
ctx context.Context,
outlet *entities.Outlet,
table *entities.Table,
categories []contract.CategoryResponse,
products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse {
outletPriceMap := make(map[uuid.UUID]float64)
if h.productOutletPriceService != nil {
priceResp := h.productOutletPriceService.GetByOutlet(ctx, outlet.ID)
if priceResp != nil && !priceResp.HasErrors() {
if priceList, ok := priceResp.Data.(*contract.ListProductOutletPricesResponse); ok {
for _, p := range priceList.Prices {
outletPriceMap[p.ProductID] = p.Price
}
}
}
}
productMap := make(map[uuid.UUID][]contract.ProductResponse)
for _, p := range products {
productMap[p.CategoryID] = append(productMap[p.CategoryID], p)
}
menuCategories := make([]contract.SelfOrderMenuCategory, 0, len(categories))
for _, cat := range categories {
menuItems := make([]contract.SelfOrderMenuItem, 0)
if prods, ok := productMap[cat.ID]; ok {
for _, p := range prods {
price := p.Price
if outletPrice, exists := outletPriceMap[p.ID]; exists {
price = outletPrice
}
item := contract.SelfOrderMenuItem{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Price: price,
ImageURL: p.ImageURL,
}
for _, v := range p.Variants {
item.Variants = append(item.Variants, contract.SelfOrderMenuVariant{
ID: v.ID,
Name: v.Name,
PriceModifier: v.PriceModifier,
})
}
menuItems = append(menuItems, item)
}
}
menuCategories = append(menuCategories, contract.SelfOrderMenuCategory{
ID: cat.ID,
Name: cat.Name,
Description: cat.Description,
Order: cat.Order,
Products: menuItems,
})
}
return &contract.SelfOrderMenuResponse{
OutletName: outlet.Name,
TableName: table.TableName,
Categories: menuCategories,
}
}
func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ctx := c.Request.Context()
var req contract.SelfOrderCreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> request binding failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
if err := h.validateCreateOrderRequest(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
session, table, _, err := h.resolveSession(ctx, req.SessionID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
if session == nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found or expired"),
}), "SelfOrderHandler::CreateOrder")
return
}
if !table.IsActive || !table.IsAvailable() {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not available for ordering"),
}), "SelfOrderHandler::CreateOrder")
return
}
userID, err := h.resolveOrgUser(ctx, table.OrganizationID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to resolve org user")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to create self-order"),
}), "SelfOrderHandler::CreateOrder")
return
}
orderItems := make([]models.CreateOrderItemRequest, 0, len(req.OrderItems))
for _, item := range req.OrderItems {
orderItems = append(orderItems, models.CreateOrderItemRequest{
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Notes: item.Notes,
})
}
metadata := make(map[string]interface{})
metadata["self_order"] = true
metadata["session_id"] = session.ID
metadata["customer_name"] = req.CustomerName
tableID := table.ID
modelReq := &models.CreateOrderRequest{
OutletID: table.OutletID,
UserID: userID,
TableID: &tableID,
TableNumber: &table.TableName,
OrderType: constants.OrderType(req.OrderType),
OrderItems: orderItems,
Metadata: metadata,
}
response, err := h.orderService.CreateOrder(ctx, modelReq, table.OrganizationID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to create order")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, err.Error()),
}), "SelfOrderHandler::CreateOrder")
return
}
contractResp := transformer.OrderModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder")
}
func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
ctx := c.Request.Context()
sessionID := c.Param("session_id")
if sessionID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "session_id is required"),
}), "SelfOrderHandler::GetOrdersBySession")
return
}
session, err := h.sessionRepo.GetByID(ctx, sessionID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetOrdersBySession -> failed to get session")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found"),
}), "SelfOrderHandler::GetOrdersBySession")
return
}
if session == nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found"),
}), "SelfOrderHandler::GetOrdersBySession")
return
}
orders, err := h.orderRepo.ListBySessionID(ctx, sessionID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetOrdersBySession -> failed to list orders")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.OrderServiceEntity, "failed to list orders"),
}), "SelfOrderHandler::GetOrdersBySession")
return
}
modelOrders := mappers.OrderEntitiesToResponses(orders)
contractOrders := make([]contract.OrderResponse, len(modelOrders))
for i := range modelOrders {
contractOrders[i] = *transformer.OrderModelToContract(&modelOrders[i])
}
resp := &contract.SelfOrderListOrdersResponse{
Orders: contractOrders,
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::GetOrdersBySession")
}
func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCreateOrderRequest) error {
if req.SessionID == "" {
return fmt.Errorf("session_id is required")
}
if len(req.OrderItems) == 0 {
return fmt.Errorf("at least one order item is required")
}
for i, item := range req.OrderItems {
if item.ProductID == uuid.Nil {
return fmt.Errorf("product_id is required for item %d", i+1)
}
if item.Quantity <= 0 {
return fmt.Errorf("quantity must be greater than zero for item %d", i+1)
}
}
return nil
}
func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
ctx := c.Request.Context()
var req contract.SelfOrderListCategoriesRequest
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ListCategories -> query binding failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
}), "SelfOrderHandler::ListCategories")
return
}
if req.OrganizationID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organization_id is required"),
}), "SelfOrderHandler::ListCategories")
return
}
if req.OutletID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "outlet_id is required"),
}), "SelfOrderHandler::ListCategories")
return
}
orgID, err := uuid.Parse(req.OrganizationID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organization_id format"),
}), "SelfOrderHandler::ListCategories")
return
}
outletID, err := uuid.Parse(req.OutletID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid outlet_id format"),
}), "SelfOrderHandler::ListCategories")
return
}
outlet, err := h.outletRepo.GetByID(ctx, outletID)
if err != nil || outlet == nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "outlet not found"),
}), "SelfOrderHandler::ListCategories")
return
}
if outlet.OrganizationID != orgID {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "outlet does not belong to the specified organization"),
}), "SelfOrderHandler::ListCategories")
return
}
catResp := h.categoryService.ListCategories(ctx, &contract.ListCategoriesRequest{
OrganizationID: &orgID,
Page: 1,
Limit: 100,
})
if catResp.HasErrors() {
logger.FromContext(ctx).WithError(catResp.GetErrors()[0]).Error("SelfOrderHandler::ListCategories -> failed to list categories")
util.HandleResponse(c.Writer, c.Request, catResp, "SelfOrderHandler::ListCategories")
return
}
catList, ok := catResp.Data.(*contract.ListCategoriesResponse)
if !ok {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.InternalServerErrorCode, constants.CategoryServiceEntity, "unexpected categories response type"),
}), "SelfOrderHandler::ListCategories")
return
}
items := make([]contract.SelfOrderCategoryItem, 0, len(catList.Categories))
for _, cat := range catList.Categories {
items = append(items, contract.SelfOrderCategoryItem{
ID: cat.ID,
Name: cat.Name,
Description: cat.Description,
Order: cat.Order,
})
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(&contract.SelfOrderListCategoriesResponse{
Categories: items,
}), "SelfOrderHandler::ListCategories")
}
func (h *SelfOrderHandler) resolveSession(ctx context.Context, sessionID string) (*models.SelfOrderSession, *entities.Table, *entities.Outlet, error) {
session, err := h.sessionRepo.GetByID(ctx, sessionID)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to get session: %w", err)
}
if session == nil {
return nil, nil, nil, nil
}
if session.Status != "active" {
return nil, nil, nil, fmt.Errorf("session is no longer active")
}
table, err := h.tableRepo.GetByID(ctx, session.TableID)
if err != nil {
return nil, nil, nil, fmt.Errorf("table not found for session")
}
outlet, err := h.outletRepo.GetByID(ctx, table.OutletID)
if err != nil {
return nil, nil, nil, fmt.Errorf("outlet not found for session")
}
return session, table, outlet, nil
}
func (h *SelfOrderHandler) resolveOrgUser(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
users, err := h.userRepo.GetByOrganizationID(ctx, organizationID)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to get users for organization: %w", err)
}
if len(users) == 0 {
return uuid.Nil, fmt.Errorf("no users found for organization")
}
return users[0].ID, nil
}
+53 -1
View File
@@ -5,8 +5,11 @@ import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/pkg/qrcode"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -16,12 +19,14 @@ import (
type TableHandler struct {
tableService TableService
tableValidator *validator.TableValidator
selfOrderURL string
}
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator) *TableHandler {
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, selfOrderURL string) *TableHandler {
return &TableHandler{
tableService: tableService,
tableValidator: tableValidator,
selfOrderURL: selfOrderURL,
}
}
@@ -145,6 +150,11 @@ func (h *TableHandler) List(c *gin.Context) {
Limit: 100,
}
// Fallback to context outlet ID if not provided in query
if query.OutletID == "" && contextInfo.OutletID != uuid.Nil {
query.OutletID = contextInfo.OutletID.String()
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
query.Page = page
@@ -286,3 +296,45 @@ func (h *TableHandler) GetOccupiedTables(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, response, "TableHandler::GetOccupiedTables")
}
func (h *TableHandler) GenerateQRCode(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
tableID, err := uuid.Parse(id)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> Invalid table ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid table ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GenerateQRCode")
return
}
token, err := h.tableService.GetTableToken(ctx, tableID)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> table not found")
validationResponseError := contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "Table not found")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GenerateQRCode")
return
}
selfOrderURLResult := fmt.Sprintf("%s/menu?token=%s", h.selfOrderURL, token)
size := 256
if sizeStr := c.Query("size"); sizeStr != "" {
if s, err := strconv.Atoi(sizeStr); err == nil && s > 0 && s <= 1024 {
size = s
}
}
pngBytes, err := qrcode.GeneratePNG(selfOrderURLResult, size)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> QR generation failed")
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, "Failed to generate QR code")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "TableHandler::GenerateQRCode")
return
}
c.Header("Content-Type", "image/png")
c.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"table-%s-qr.png\"", tableID))
c.Data(http.StatusOK, "image/png", pngBytes)
}
+1
View File
@@ -17,4 +17,5 @@ type TableService interface {
ReleaseTable(ctx context.Context, tableID uuid.UUID, req *contract.ReleaseTableRequest) *contract.Response
GetAvailableTables(ctx context.Context, outletID uuid.UUID) *contract.Response
GetOccupiedTables(ctx context.Context, outletID uuid.UUID) *contract.Response
GetTableToken(ctx context.Context, tableID uuid.UUID) (string, error)
}
+215
View File
@@ -0,0 +1,215 @@
package handler
import (
"strconv"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type UserDeviceHandler struct {
userDeviceService service.UserDeviceService
userDeviceValidator validator.UserDeviceValidator
}
func NewUserDeviceHandler(
userDeviceService service.UserDeviceService,
userDeviceValidator validator.UserDeviceValidator,
) *UserDeviceHandler {
return &UserDeviceHandler{
userDeviceService: userDeviceService,
userDeviceValidator: userDeviceValidator,
}
}
func (h *UserDeviceHandler) RegisterDevice(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.RegisterUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::RegisterDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateRegisterDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
deviceResponse := h.userDeviceService.RegisterDevice(ctx, contextInfo.UserID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::RegisterDevice -> Failed to register device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::RegisterDevice")
}
func (h *UserDeviceHandler) UpdateDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
var req contract.UpdateUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateUpdateDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
deviceResponse := h.userDeviceService.UpdateDevice(ctx, deviceID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::UpdateDevice -> Failed to update device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::UpdateDevice")
}
func (h *UserDeviceHandler) DeleteDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::DeleteDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::DeleteDevice")
return
}
deviceResponse := h.userDeviceService.DeleteDevice(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::DeleteDevice -> Failed to delete device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::DeleteDevice")
}
func (h *UserDeviceHandler) GetDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevice")
return
}
deviceResponse := h.userDeviceService.GetDeviceByID(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevice -> Failed to get device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevice")
}
func (h *UserDeviceHandler) GetMyDevices(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, contextInfo.UserID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetMyDevices -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetMyDevices")
}
func (h *UserDeviceHandler) GetDevicesByUser(c *gin.Context) {
ctx := c.Request.Context()
userIDStr := c.Param("user_id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevicesByUser -> Invalid user ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid user ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevicesByUser")
return
}
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, userID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevicesByUser -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevicesByUser")
}
func (h *UserDeviceHandler) ListDevices(c *gin.Context) {
ctx := c.Request.Context()
req := &contract.ListUserDevicesRequest{
Page: 1,
Limit: 10,
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
req.Page = page
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil {
req.Limit = limit
}
}
if userID := c.Query("user_id"); userID != "" {
req.UserID = userID
}
if platform := c.Query("platform"); platform != "" {
req.Platform = platform
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateListDevicesRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::ListDevices")
return
}
deviceResponse := h.userDeviceService.ListDevices(ctx, req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::ListDevices -> Failed to list devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::ListDevices")
}
+14 -8
View File
@@ -13,11 +13,12 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
return &models.Category{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name,
Description: entity.Description,
ImageURL: nil, // Entity doesn't have ImageURL, model does
Order: entity.Order, // Entity doesn't have SortOrder, model does
IsActive: true, // Entity doesn't have IsActive, default to true
ImageURL: nil,
Order: entity.Order,
IsActive: true,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -32,14 +33,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
if model.ImageURL != nil {
metadata["image_url"] = *model.ImageURL
}
// metadata["sort_order"] = model.SortOrder
return &entities.Category{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Name: model.Name,
Description: model.Description,
BusinessType: "restaurant", // Default business type
BusinessType: "restaurant",
Order: model.Order,
Metadata: metadata,
CreatedAt: model.CreatedAt,
@@ -56,14 +57,14 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
if req.ImageURL != nil {
metadata["image_url"] = *req.ImageURL
}
// metadata["sort_order"] = req.SortOrder
return &entities.Category{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
Name: req.Name,
Description: req.Description,
Order: req.Order,
BusinessType: "restaurant", // Default business type
BusinessType: "restaurant",
Metadata: metadata,
}
}
@@ -87,11 +88,12 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
return &models.CategoryResponse{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name,
Description: entity.Description,
ImageURL: imageURL,
Order: entity.Order,
IsActive: true, // Default to true since entity doesn't have this field
IsActive: true,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -121,6 +123,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
if req.Order != nil {
entity.Order = *req.Order
}
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
}
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
+85
View File
@@ -0,0 +1,85 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func NotificationEntityToResponse(e *entities.Notification) *models.NotificationResponse {
if e == nil {
return nil
}
return &models.NotificationResponse{
ID: e.ID,
Title: e.Title,
Body: e.Body,
Type: e.Type,
Category: e.Category,
Priority: e.Priority,
ImageURL: e.ImageURL,
ActionURL: e.ActionURL,
NotifiableType: e.NotifiableType,
NotifiableID: e.NotifiableID,
Data: e.Data,
ScheduledAt: e.ScheduledAt,
SentAt: e.SentAt,
ExpiredAt: e.ExpiredAt,
CreatedBy: e.CreatedBy,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
func NotificationReceiverEntityToResponse(e *entities.NotificationReceiver) *models.NotificationReceiverResponse {
if e == nil {
return nil
}
resp := &models.NotificationReceiverResponse{
ID: e.ID,
NotificationID: e.NotificationID,
UserID: e.UserID,
IsRead: e.IsRead,
ReadAt: e.ReadAt,
IsDeleted: e.IsDeleted,
DeletedAt: e.DeletedAt,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
if e.Notification != nil {
resp.Notification = NotificationEntityToResponse(e.Notification)
}
return resp
}
func NotificationReceiverEntitiesToResponses(entities []*entities.NotificationReceiver) []*models.NotificationReceiverResponse {
if entities == nil {
return nil
}
responses := make([]*models.NotificationReceiverResponse, len(entities))
for i, e := range entities {
responses[i] = NotificationReceiverEntityToResponse(e)
}
return responses
}
func NotificationDeliveryEntityToResponse(e *entities.NotificationDelivery) *models.NotificationDeliveryResponse {
if e == nil {
return nil
}
return &models.NotificationDeliveryResponse{
ID: e.ID,
NotificationReceiverID: e.NotificationReceiverID,
UserDeviceID: e.UserDeviceID,
Channel: e.Channel,
DeliveryStatus: e.DeliveryStatus,
Provider: e.Provider,
ProviderMessageID: e.ProviderMessageID,
SentAt: e.SentAt,
DeliveredAt: e.DeliveredAt,
FailedAt: e.FailedAt,
FailureReason: e.FailureReason,
RetryCount: e.RetryCount,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
}
}
+1
View File
@@ -135,6 +135,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
Name: entity.Name,
Description: entity.Description,
Price: entity.Price,
OutletPrice: nil, // populated by processor when outletID is available
Cost: entity.Cost,
BusinessType: constants.BusinessType(entity.BusinessType),
ImageURL: entity.ImageURL,
@@ -0,0 +1,48 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func ProductOutletPriceEntityToModel(entity *entities.ProductOutletPrice) *models.ProductOutletPrice {
if entity == nil {
return nil
}
return &models.ProductOutletPrice{
ID: entity.ID,
ProductID: entity.ProductID,
OutletID: entity.OutletID,
Price: entity.Price,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func ProductOutletPriceModelToEntity(model *models.ProductOutletPrice) *entities.ProductOutletPrice {
if model == nil {
return nil
}
return &entities.ProductOutletPrice{
ID: model.ID,
ProductID: model.ProductID,
OutletID: model.OutletID,
Price: model.Price,
CreatedAt: model.CreatedAt,
UpdatedAt: model.UpdatedAt,
}
}
func ProductOutletPriceEntitiesToModels(entities []*entities.ProductOutletPrice) []*models.ProductOutletPrice {
if entities == nil {
return nil
}
models := make([]*models.ProductOutletPrice, len(entities))
for i, entity := range entities {
models[i] = ProductOutletPriceEntityToModel(entity)
}
return models
}
+62
View File
@@ -0,0 +1,62 @@
package mappers
import (
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
)
func UserDeviceEntityToModel(entity *entities.UserDevice) *models.UserDevice {
if entity == nil {
return nil
}
return &models.UserDevice{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntityToResponse(entity *entities.UserDevice) *models.UserDeviceResponse {
if entity == nil {
return nil
}
return &models.UserDeviceResponse{
ID: entity.ID,
UserID: entity.UserID,
DeviceID: entity.DeviceID,
DeviceName: entity.DeviceName,
DeviceType: entity.DeviceType,
Platform: entity.Platform,
FCMToken: entity.FCMToken,
AppVersion: entity.AppVersion,
OsVersion: entity.OsVersion,
IPAddress: entity.IPAddress,
LastActiveAt: entity.LastActiveAt,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
}
func UserDeviceEntitiesToResponses(entities []*entities.UserDevice) []*models.UserDeviceResponse {
if entities == nil {
return nil
}
responses := make([]*models.UserDeviceResponse, len(entities))
for i, entity := range entities {
responses[i] = UserDeviceEntityToResponse(entity)
}
return responses
}
+7 -2
View File
@@ -11,6 +11,7 @@ import (
"apskel-pos-be/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AuthMiddleware struct {
@@ -45,9 +46,13 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
setKeyInContext(c, appcontext.OrganizationIDKey, userResponse.OrganizationID.String())
setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String())
if userResponse.Role != "superadmin" {
setKeyInContext(c, appcontext.OutletIDKey, userResponse.OutletID.String())
// Always override OutletID from token to prevent header injection.
// Set empty string if user has no outlet, so PopulateContext header value is ignored.
outletIDStr := ""
if userResponse.OutletID != nil && *userResponse.OutletID != uuid.Nil {
outletIDStr = userResponse.OutletID.String()
}
setKeyInContext(c, appcontext.OutletIDKey, outletIDStr)
logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email)
c.Next()
+63
View File
@@ -87,6 +87,69 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"`
}
// PurchasingAnalyticsRequest represents the request for purchasing analytics
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID `validate:"omitempty"`
DateFrom time.Time `validate:"required"`
DateTo time.Time `validate:"required"`
GroupBy string `validate:"omitempty,oneof=day hour week month"`
}
// PurchasingAnalyticsResponse represents the response for purchasing analytics
type PurchasingAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"`
Summary PurchasingSummary `json:"summary"`
Data []PurchasingAnalyticsData `json:"data"`
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
VendorData []PurchasingVendorData `json:"vendor_data"`
}
// PurchasingSummary represents the summary of purchasing analytics
type PurchasingSummary struct {
TotalPurchases float64 `json:"total_purchases"`
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
TotalQuantity float64 `json:"total_quantity"`
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
TotalIngredients int64 `json:"total_ingredients"`
TotalVendors int64 `json:"total_vendors"`
}
// PurchasingAnalyticsData represents purchasing analytics by time period
type PurchasingAnalyticsData struct {
Date time.Time `json:"date"`
Purchases float64 `json:"purchases"`
PurchaseOrders int64 `json:"purchase_orders"`
Quantity float64 `json:"quantity"`
Ingredients int64 `json:"ingredients"`
Vendors int64 `json:"vendors"`
}
// PurchasingIngredientData represents purchasing analytics for an ingredient
type PurchasingIngredientData struct {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
// PurchasingVendorData represents purchasing analytics for a vendor
type PurchasingVendorData struct {
VendorID uuid.UUID `json:"vendor_id"`
VendorName string `json:"vendor_name"`
TotalCost float64 `json:"total_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
IngredientCount int64 `json:"ingredient_count"`
Quantity float64 `json:"quantity"`
}
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID `validate:"required"`
+4
View File
@@ -9,6 +9,7 @@ import (
type Category struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string
Description *string
ImageURL *string
@@ -20,6 +21,7 @@ type Category struct {
type CreateCategoryRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID *uuid.UUID
Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
@@ -30,6 +32,7 @@ type UpdateCategoryRequest struct {
Name *string `validate:"omitempty,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
OutletID *uuid.UUID
Order *int `validate:"omitempty,min=0"`
IsActive *bool
}
@@ -37,6 +40,7 @@ type UpdateCategoryRequest struct {
type CategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string
Description *string
ImageURL *string
+118
View File
@@ -0,0 +1,118 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request models ----
type SendNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ReceiverIDs []uuid.UUID `json:"receiver_ids"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
OrganizationID uuid.UUID `json:"organization_id"`
ScheduledAt *time.Time `json:"scheduled_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
}
type MarkNotificationReadRequest struct {
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserID uuid.UUID `json:"user_id"`
}
type ListNotificationsRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID uuid.UUID `json:"user_id"`
IsRead *bool `json:"is_read"`
}
// ---- Response models ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type NotificationDeliveryResponse struct {
ID uuid.UUID `json:"id"`
NotificationReceiverID uuid.UUID `json:"notification_receiver_id"`
UserDeviceID uuid.UUID `json:"user_device_id"`
Channel entities.NotificationChannel `json:"channel"`
DeliveryStatus entities.NotificationDeliveryStatus `json:"delivery_status"`
Provider entities.NotificationProvider `json:"provider"`
ProviderMessageID string `json:"provider_message_id"`
SentAt *time.Time `json:"sent_at"`
DeliveredAt *time.Time `json:"delivered_at"`
FailedAt *time.Time `json:"failed_at"`
FailureReason string `json:"failure_reason"`
RetryCount int `json:"retry_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int `json:"total_count"`
UnreadCount int `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+10
View File
@@ -40,6 +40,7 @@ type ProductVariant struct {
type CreateProductRequest struct {
OrganizationID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on create
CategoryID uuid.UUID `validate:"required"`
SKU *string `validate:"omitempty,max=100"`
Name string `validate:"required,min=1,max=255"`
@@ -60,6 +61,7 @@ type CreateProductRequest struct {
}
type UpdateProductRequest struct {
OutletID uuid.UUID `validate:"omitempty"` // If set, upsert product_outlet_prices on update
CategoryID *uuid.UUID `validate:"omitempty"`
SKU *string `validate:"omitempty,max=100"`
Name *string `validate:"omitempty,min=1,max=255"`
@@ -100,6 +102,8 @@ type ProductResponse struct {
Name string
Description *string
Price float64
OutletPrice *float64 // outlet-specific price, nil if not set
OutletPrices []OutletPrice // all outlet prices, populated when no outletID in context
Cost float64
BusinessType constants.BusinessType
ImageURL *string
@@ -113,6 +117,12 @@ type ProductResponse struct {
Variants []ProductVariantResponse
}
type OutletPrice struct {
OutletID uuid.UUID
OutletName string
Price float64
}
type ProductVariantResponse struct {
ID uuid.UUID
ProductID uuid.UUID
+35
View File
@@ -0,0 +1,35 @@
package models
import (
"time"
"github.com/google/uuid"
)
type ProductOutletPrice struct {
ID uuid.UUID
ProductID uuid.UUID
OutletID uuid.UUID
Price float64
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `validate:"required"`
OutletID uuid.UUID `validate:"required"`
Price float64 `validate:"required,min=0"`
}
type UpdateProductOutletPriceRequest struct {
Price *float64 `validate:"required,min=0"`
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id"`
ProductID uuid.UUID `json:"product_id"`
OutletID uuid.UUID `json:"outlet_id"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+18
View File
@@ -0,0 +1,18 @@
package models
import (
"time"
"github.com/google/uuid"
)
type SelfOrderSession struct {
ID string `json:"id"`
TableID uuid.UUID `json:"table_id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Status string `json:"status"`
CustomerName string `json:"customer_name"`
CreatedAt time.Time `json:"created_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
+77
View File
@@ -0,0 +1,77 @@
package models
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type UserDevice struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RegisterUserDeviceRequest struct {
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
}
type ListUserDevicesRequest struct {
Page int `json:"page"`
Limit int `json:"limit"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []*UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+32
View File
@@ -0,0 +1,32 @@
package qrcode
import (
"bytes"
"image/png"
"github.com/boombuler/barcode"
"github.com/boombuler/barcode/qr"
)
func GeneratePNG(content string, size int) ([]byte, error) {
if size <= 0 {
size = 256
}
qrCode, err := qr.Encode(content, qr.M, qr.Auto)
if err != nil {
return nil, err
}
qrCode, err = barcode.Scale(qrCode, size, size)
if err != nil {
return nil, err
}
var buf bytes.Buffer
if err := png.Encode(&buf, qrCode); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+43
View File
@@ -0,0 +1,43 @@
package tabletoken
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
type TableTokenPayload struct {
TableID uuid.UUID `json:"table_id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
}
func Encode(tableID, organizationID, outletID uuid.UUID) string {
payload := TableTokenPayload{
TableID: tableID,
OrganizationID: organizationID,
OutletID: outletID,
}
jsonBytes, _ := json.Marshal(payload)
return base64.URLEncoding.EncodeToString(jsonBytes)
}
func Decode(token string) (tableID, organizationID, outletID uuid.UUID, err error) {
jsonBytes, err := base64.URLEncoding.DecodeString(token)
if err != nil {
return uuid.Nil, uuid.Nil, uuid.Nil, fmt.Errorf("invalid token encoding: %w", err)
}
var payload TableTokenPayload
if err := json.Unmarshal(jsonBytes, &payload); err != nil {
return uuid.Nil, uuid.Nil, uuid.Nil, fmt.Errorf("invalid token format: %w", err)
}
if payload.TableID == uuid.Nil || payload.OrganizationID == uuid.Nil || payload.OutletID == uuid.Nil {
return uuid.Nil, uuid.Nil, uuid.Nil, fmt.Errorf("token missing required fields")
}
return payload.TableID, payload.OrganizationID, payload.OutletID, nil
}
+72
View File
@@ -12,6 +12,7 @@ import (
type AnalyticsProcessor interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
@@ -164,6 +165,77 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
}, nil
}
func (p *AnalyticsProcessorImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy == "" {
req.GroupBy = "day"
}
result, err := p.analyticsRepo.GetPurchasingAnalytics(ctx, req.OrganizationID, req.OutletID, req.DateFrom, req.DateTo, req.GroupBy)
if err != nil {
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
}
data := make([]models.PurchasingAnalyticsData, len(result.Data))
for i, item := range result.Data {
data[i] = models.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]models.PurchasingIngredientData, len(result.IngredientData))
for i, item := range result.IngredientData {
ingredientData[i] = models.PurchasingIngredientData{
IngredientID: item.IngredientID,
IngredientName: item.IngredientName,
Quantity: item.Quantity,
TotalCost: item.TotalCost,
AverageUnitCost: item.AverageUnitCost,
PurchaseOrderCount: item.PurchaseOrderCount,
}
}
vendorData := make([]models.PurchasingVendorData, len(result.VendorData))
for i, item := range result.VendorData {
vendorData[i] = models.PurchasingVendorData{
VendorID: item.VendorID,
VendorName: item.VendorName,
TotalCost: item.TotalCost,
PurchaseOrderCount: item.PurchaseOrderCount,
IngredientCount: item.IngredientCount,
Quantity: item.Quantity,
}
}
return &models.PurchasingAnalyticsResponse{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletName: result.OutletName,
DateFrom: req.DateFrom,
DateTo: req.DateTo,
GroupBy: req.GroupBy,
Summary: models.PurchasingSummary{
TotalPurchases: result.Summary.TotalPurchases,
TotalPurchaseOrders: result.Summary.TotalPurchaseOrders,
TotalQuantity: result.Summary.TotalQuantity,
AveragePurchaseOrderValue: result.Summary.AveragePurchaseOrderValue,
TotalIngredients: result.Summary.TotalIngredients,
TotalVendors: result.Summary.TotalVendors,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
}, nil
}
func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
// Validate date range
if req.DateFrom.After(req.DateTo) {
@@ -0,0 +1,73 @@
package processor
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type analyticsRepositoryStub struct {
purchasingResult *entities.PurchasingAnalytics
}
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
return nil, nil
}
func (s analyticsRepositoryStub) GetPurchasingAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.PurchasingAnalytics, error) {
return s.purchasingResult, nil
}
func (analyticsRepositoryStub) GetProductAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, int) ([]*entities.ProductAnalytics, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerCategory, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetDashboardOverview(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) (*entities.DashboardOverview, error) {
return nil, nil
}
func (analyticsRepositoryStub) GetProfitLossAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) (*entities.ProfitLossAnalytics, error) {
return nil, nil
}
func TestAnalyticsProcessorGetPurchasingAnalyticsPassesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
processor := NewAnalyticsProcessorImpl(analyticsRepositoryStub{
purchasingResult: &entities.PurchasingAnalytics{
OutletName: &outletName,
Summary: entities.PurchasingSummary{
TotalPurchases: 125,
},
},
})
result, err := processor.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
OutletID: &outletID,
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
require.Equal(t, float64(125), result.Summary.TotalPurchases)
}
@@ -0,0 +1,338 @@
package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
// NotificationRepository is the interface the processor depends on.
type NotificationRepository interface {
Create(ctx context.Context, notification *entities.Notification) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Notification, error)
Update(ctx context.Context, notification *entities.Notification) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Notification, int64, error)
}
// NotificationReceiverRepository is the interface the processor depends on.
type NotificationReceiverRepository interface {
Create(ctx context.Context, receiver *entities.NotificationReceiver) error
BulkCreate(ctx context.Context, receivers []*entities.NotificationReceiver) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationReceiver, error)
GetByNotificationAndUser(ctx context.Context, notificationID, userID uuid.UUID) (*entities.NotificationReceiver, error)
Update(ctx context.Context, receiver *entities.NotificationReceiver) error
ListByUserID(ctx context.Context, userID uuid.UUID, isRead *bool, limit, offset int) ([]*entities.NotificationReceiver, int64, error)
CountUnreadByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
SoftDeleteByID(ctx context.Context, id uuid.UUID) error
}
// NotificationDeliveryRepository is the interface the processor depends on.
type NotificationDeliveryRepository interface {
Create(ctx context.Context, delivery *entities.NotificationDelivery) error
BulkCreate(ctx context.Context, deliveries []*entities.NotificationDelivery) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationDelivery, error)
Update(ctx context.Context, delivery *entities.NotificationDelivery) error
ListByReceiverID(ctx context.Context, receiverID uuid.UUID) ([]*entities.NotificationDelivery, error)
}
// NotificationUserRepository is a minimal interface to fetch user devices.
type NotificationUserDeviceRepository interface {
GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entities.UserDevice, error)
}
// NotificationUserRepository is a minimal interface to fetch users by org.
type NotificationUserRepository interface {
GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error)
}
// NotificationProcessor defines the business logic interface.
type NotificationProcessor interface {
Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error)
Broadcast(ctx context.Context, req *models.BroadcastNotificationRequest) (*models.NotificationResponse, error)
MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) (*models.NotificationReceiverResponse, error)
MarkAllAsRead(ctx context.Context, userID uuid.UUID) error
DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) error
ListForUser(ctx context.Context, req *models.ListNotificationsRequest) ([]*models.NotificationReceiverResponse, int64, int64, error)
GetByID(ctx context.Context, id uuid.UUID) (*models.NotificationResponse, error)
}
type NotificationProcessorImpl struct {
notificationRepo NotificationRepository
receiverRepo NotificationReceiverRepository
deliveryRepo NotificationDeliveryRepository
userDeviceRepo NotificationUserDeviceRepository
userRepo NotificationUserRepository
fcmClient client.FCMClient
}
func NewNotificationProcessor(
notificationRepo NotificationRepository,
receiverRepo NotificationReceiverRepository,
deliveryRepo NotificationDeliveryRepository,
userDeviceRepo NotificationUserDeviceRepository,
userRepo NotificationUserRepository,
fcmClient client.FCMClient,
) *NotificationProcessorImpl {
return &NotificationProcessorImpl{
notificationRepo: notificationRepo,
receiverRepo: receiverRepo,
deliveryRepo: deliveryRepo,
userDeviceRepo: userDeviceRepo,
userRepo: userRepo,
fcmClient: fcmClient,
}
}
// Send creates a notification and dispatches it to the given receiver user IDs via FCM.
func (p *NotificationProcessorImpl) Send(ctx context.Context, req *models.SendNotificationRequest) (*models.NotificationResponse, error) {
if len(req.ReceiverIDs) == 0 {
return nil, fmt.Errorf("at least one receiver_id is required")
}
notification := &entities.Notification{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: req.CreatedBy,
}
if err := p.notificationRepo.Create(ctx, notification); err != nil {
return nil, fmt.Errorf("failed to create notification: %w", err)
}
// Create receiver records and dispatch FCM per user.
for _, userID := range req.ReceiverIDs {
receiver := &entities.NotificationReceiver{
NotificationID: notification.ID,
UserID: userID,
}
if err := p.receiverRepo.Create(ctx, receiver); err != nil {
// Log but continue for other receivers.
continue
}
p.dispatchFCMToUser(ctx, receiver, notification)
}
// Mark notification as sent.
now := time.Now()
notification.SentAt = &now
_ = p.notificationRepo.Update(ctx, notification)
return mappers.NotificationEntityToResponse(notification), nil
}
// Broadcast sends a notification to all active users in an organization.
func (p *NotificationProcessorImpl) Broadcast(ctx context.Context, req *models.BroadcastNotificationRequest) (*models.NotificationResponse, error) {
users, err := p.userRepo.GetActiveUsers(ctx, req.OrganizationID)
if err != nil {
return nil, fmt.Errorf("failed to fetch organization users: %w", err)
}
notification := &entities.Notification{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: req.CreatedBy,
}
if err := p.notificationRepo.Create(ctx, notification); err != nil {
return nil, fmt.Errorf("failed to create notification: %w", err)
}
// Build receiver records in bulk.
receivers := make([]*entities.NotificationReceiver, 0, len(users))
for _, u := range users {
receivers = append(receivers, &entities.NotificationReceiver{
NotificationID: notification.ID,
UserID: u.ID,
})
}
if err := p.receiverRepo.BulkCreate(ctx, receivers); err != nil {
return nil, fmt.Errorf("failed to create notification receivers: %w", err)
}
// Dispatch FCM for each receiver.
for _, receiver := range receivers {
p.dispatchFCMToUser(ctx, receiver, notification)
}
now := time.Now()
notification.SentAt = &now
_ = p.notificationRepo.Update(ctx, notification)
return mappers.NotificationEntityToResponse(notification), nil
}
// MarkAsRead marks a single notification receiver record as read.
func (p *NotificationProcessorImpl) MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) (*models.NotificationReceiverResponse, error) {
receiver, err := p.receiverRepo.GetByID(ctx, receiverID)
if err != nil {
return nil, fmt.Errorf("notification not found: %w", err)
}
if receiver.UserID != userID {
return nil, fmt.Errorf("unauthorized: notification does not belong to user")
}
if !receiver.IsRead {
now := time.Now()
receiver.IsRead = true
receiver.ReadAt = &now
if err := p.receiverRepo.Update(ctx, receiver); err != nil {
return nil, fmt.Errorf("failed to mark notification as read: %w", err)
}
}
return mappers.NotificationReceiverEntityToResponse(receiver), nil
}
// MarkAllAsRead marks all unread notifications for a user as read.
func (p *NotificationProcessorImpl) MarkAllAsRead(ctx context.Context, userID uuid.UUID) error {
isRead := false
receivers, _, err := p.receiverRepo.ListByUserID(ctx, userID, &isRead, 1000, 0)
if err != nil {
return fmt.Errorf("failed to fetch unread notifications: %w", err)
}
now := time.Now()
for _, r := range receivers {
r.IsRead = true
r.ReadAt = &now
_ = p.receiverRepo.Update(ctx, r)
}
return nil
}
// DeleteForUser soft-deletes a notification receiver record for a user.
func (p *NotificationProcessorImpl) DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) error {
receiver, err := p.receiverRepo.GetByID(ctx, receiverID)
if err != nil {
return fmt.Errorf("notification not found: %w", err)
}
if receiver.UserID != userID {
return fmt.Errorf("unauthorized: notification does not belong to user")
}
return p.receiverRepo.SoftDeleteByID(ctx, receiverID)
}
// ListForUser returns paginated notifications for a user.
// Returns: receivers, total, unreadCount, error
func (p *NotificationProcessorImpl) ListForUser(ctx context.Context, req *models.ListNotificationsRequest) ([]*models.NotificationReceiverResponse, int64, int64, error) {
offset := (req.Page - 1) * req.Limit
receivers, total, err := p.receiverRepo.ListByUserID(ctx, req.UserID, req.IsRead, req.Limit, offset)
if err != nil {
return nil, 0, 0, fmt.Errorf("failed to list notifications: %w", err)
}
unreadCount, err := p.receiverRepo.CountUnreadByUserID(ctx, req.UserID)
if err != nil {
unreadCount = 0
}
responses := mappers.NotificationReceiverEntitiesToResponses(receivers)
return responses, total, unreadCount, nil
}
// GetByID returns a single notification by its ID.
func (p *NotificationProcessorImpl) GetByID(ctx context.Context, id uuid.UUID) (*models.NotificationResponse, error) {
notification, err := p.notificationRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("notification not found: %w", err)
}
return mappers.NotificationEntityToResponse(notification), nil
}
// dispatchFCMToUser fetches all FCM tokens for a user and sends the push notification.
func (p *NotificationProcessorImpl) dispatchFCMToUser(ctx context.Context, receiver *entities.NotificationReceiver, notification *entities.Notification) {
if p.fcmClient == nil {
return
}
devices, err := p.userDeviceRepo.GetByUserID(ctx, receiver.UserID)
if err != nil || len(devices) == 0 {
return
}
// Build FCM data payload.
data := map[string]string{
"notification_id": notification.ID.String(),
"notification_receiver_id": receiver.ID.String(),
"type": notification.Type,
"category": notification.Category,
"action_url": notification.ActionURL,
}
// Collect valid FCM tokens and create delivery records.
tokens := make([]string, 0, len(devices))
deliveries := make([]*entities.NotificationDelivery, 0, len(devices))
for _, device := range devices {
if device.FCMToken == "" {
continue
}
tokens = append(tokens, device.FCMToken)
deliveries = append(deliveries, &entities.NotificationDelivery{
NotificationReceiverID: receiver.ID,
UserDeviceID: device.ID,
Channel: entities.NotificationChannelPush,
DeliveryStatus: entities.NotificationDeliveryStatusPending,
Provider: entities.NotificationProviderFirebase,
})
}
if len(tokens) == 0 {
return
}
// Persist delivery records before sending.
_ = p.deliveryRepo.BulkCreate(ctx, deliveries)
// Send via FCM multicast.
now := time.Now()
sendErr := p.fcmClient.SendMulticastNotification(ctx, tokens, notification.Title, notification.Body, data)
// Update delivery status.
for _, delivery := range deliveries {
if sendErr != nil {
delivery.DeliveryStatus = entities.NotificationDeliveryStatusFailed
delivery.FailedAt = &now
delivery.FailureReason = sendErr.Error()
} else {
delivery.DeliveryStatus = entities.NotificationDeliveryStatusSent
delivery.SentAt = &now
}
_ = p.deliveryRepo.Update(ctx, delivery)
}
}
+15
View File
@@ -108,6 +108,7 @@ type OrderProcessorImpl struct {
productRecipeRepo *repository.ProductRecipeRepository
ingredientRepo IngredientRepository
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
}
func NewOrderProcessorImpl(
@@ -126,6 +127,7 @@ func NewOrderProcessorImpl(
productRecipeRepo *repository.ProductRecipeRepository,
ingredientRepo IngredientRepository,
inventoryMovementService InventoryMovementService,
productOutletPriceRepo repository.ProductOutletPriceRepository,
) *OrderProcessorImpl {
return &OrderProcessorImpl{
orderRepo: orderRepo,
@@ -144,6 +146,7 @@ func NewOrderProcessorImpl(
productRecipeRepo: productRecipeRepo,
ingredientRepo: ingredientRepo,
inventoryMovementService: inventoryMovementService,
productOutletPriceRepo: productOutletPriceRepo,
}
}
@@ -170,6 +173,12 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
unitPrice := product.Price
unitCost := product.Cost
if p.productOutletPriceRepo != nil {
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, req.OutletID); err == nil {
unitPrice = outletPrice.Price
}
}
if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
if err != nil {
@@ -293,6 +302,12 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
unitPrice := product.Price
unitCost := product.Cost
if p.productOutletPriceRepo != nil {
if outletPrice, err := p.productOutletPriceRepo.GetByProductAndOutlet(ctx, itemReq.ProductID, order.OutletID); err == nil {
unitPrice = outletPrice.Price
}
}
// Handle product variant if specified
if itemReq.ProductVariantID != nil {
variant, err := p.productVariantRepo.GetByID(ctx, *itemReq.ProductVariantID)
@@ -0,0 +1,121 @@
package processor
import (
"context"
"fmt"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
type ProductOutletPriceProcessor interface {
Upsert(ctx context.Context, req *models.CreateProductOutletPriceRequest) (*models.ProductOutletPrice, error)
GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*models.ProductOutletPrice, error)
GetByProduct(ctx context.Context, productID uuid.UUID) ([]*models.ProductOutletPrice, error)
GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*models.ProductOutletPrice, error)
Delete(ctx context.Context, id uuid.UUID) error
ResolvePrice(ctx context.Context, productID, outletID uuid.UUID, fallbackPrice float64) float64
BulkUpsert(ctx context.Context, productID uuid.UUID, prices []models.CreateProductOutletPriceRequest) ([]*models.ProductOutletPrice, error)
}
type ProductOutletPriceProcessorImpl struct {
repo repository.ProductOutletPriceRepository
productRepo ProductRepository
outletRepo OutletRepository
}
func NewProductOutletPriceProcessorImpl(repo repository.ProductOutletPriceRepository, productRepo ProductRepository, outletRepo OutletRepository) *ProductOutletPriceProcessorImpl {
return &ProductOutletPriceProcessorImpl{
repo: repo,
productRepo: productRepo,
outletRepo: outletRepo,
}
}
func (p *ProductOutletPriceProcessorImpl) Upsert(ctx context.Context, req *models.CreateProductOutletPriceRequest) (*models.ProductOutletPrice, error) {
if _, err := p.productRepo.GetByID(ctx, req.ProductID); err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
if _, err := p.outletRepo.GetByID(ctx, req.OutletID); err != nil {
return nil, fmt.Errorf("outlet not found: %w", err)
}
entity := &entities.ProductOutletPrice{
ProductID: req.ProductID,
OutletID: req.OutletID,
Price: req.Price,
}
if err := p.repo.Upsert(ctx, entity); err != nil {
return nil, fmt.Errorf("failed to upsert product outlet price: %w", err)
}
actual, err := p.repo.GetByProductAndOutlet(ctx, req.ProductID, req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve upserted product outlet price: %w", err)
}
return mappers.ProductOutletPriceEntityToModel(actual), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*models.ProductOutletPrice, error) {
entity, err := p.repo.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
return nil, fmt.Errorf("product outlet price not found: %w", err)
}
return mappers.ProductOutletPriceEntityToModel(entity), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*models.ProductOutletPrice, error) {
entities, err := p.repo.GetByProduct(ctx, productID)
if err != nil {
return nil, fmt.Errorf("failed to get product outlet prices: %w", err)
}
return mappers.ProductOutletPriceEntitiesToModels(entities), nil
}
func (p *ProductOutletPriceProcessorImpl) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*models.ProductOutletPrice, error) {
entities, err := p.repo.GetByOutlet(ctx, outletID)
if err != nil {
return nil, fmt.Errorf("failed to get outlet prices: %w", err)
}
return mappers.ProductOutletPriceEntitiesToModels(entities), nil
}
func (p *ProductOutletPriceProcessorImpl) Delete(ctx context.Context, id uuid.UUID) error {
if err := p.repo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete product outlet price: %w", err)
}
return nil
}
func (p *ProductOutletPriceProcessorImpl) ResolvePrice(ctx context.Context, productID, outletID uuid.UUID, fallbackPrice float64) float64 {
outletPrice, err := p.repo.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
return fallbackPrice
}
return outletPrice.Price
}
func (p *ProductOutletPriceProcessorImpl) BulkUpsert(ctx context.Context, productID uuid.UUID, prices []models.CreateProductOutletPriceRequest) ([]*models.ProductOutletPrice, error) {
var results []*models.ProductOutletPrice
for _, req := range prices {
req.ProductID = productID
result, err := p.Upsert(ctx, &req)
if err != nil {
return nil, fmt.Errorf("failed to upsert price for outlet %s: %w", req.OutletID, err)
}
results = append(results, result)
}
return results, nil
}
+96 -7
View File
@@ -16,8 +16,9 @@ type ProductProcessor interface {
CreateProduct(ctx context.Context, req *models.CreateProductRequest) (*models.ProductResponse, error)
UpdateProduct(ctx context.Context, id uuid.UUID, req *models.UpdateProductRequest) (*models.ProductResponse, error)
DeleteProduct(ctx context.Context, id uuid.UUID) error
GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error)
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error)
ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error)
}
type ProductRepository interface {
@@ -32,11 +33,13 @@ type ProductRepository interface {
Update(ctx context.Context, product *entities.Product) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Product, int64, error)
ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error)
Count(ctx context.Context, filters map[string]interface{}) (int64, error)
GetBySKU(ctx context.Context, organizationID uuid.UUID, sku string) (*entities.Product, error)
ExistsBySKU(ctx context.Context, organizationID uuid.UUID, sku string, excludeID *uuid.UUID) (bool, error)
GetByName(ctx context.Context, organizationID uuid.UUID, name string) (*entities.Product, error)
ExistsByName(ctx context.Context, organizationID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error)
UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error
GetLowCostProducts(ctx context.Context, organizationID uuid.UUID, maxCost float64) ([]*entities.Product, error)
}
@@ -47,15 +50,17 @@ type ProductProcessorImpl struct {
productVariantRepo repository.ProductVariantRepository
inventoryRepo repository.InventoryRepository
outletRepo OutletRepository
outletPriceRepo repository.ProductOutletPriceRepository
}
func NewProductProcessorImpl(productRepo ProductRepository, categoryRepo CategoryRepository, productVariantRepo repository.ProductVariantRepository, inventoryRepo repository.InventoryRepository, outletRepo OutletRepository) *ProductProcessorImpl {
func NewProductProcessorImpl(productRepo ProductRepository, categoryRepo CategoryRepository, productVariantRepo repository.ProductVariantRepository, inventoryRepo repository.InventoryRepository, outletRepo OutletRepository, outletPriceRepo repository.ProductOutletPriceRepository) *ProductProcessorImpl {
return &ProductProcessorImpl{
productRepo: productRepo,
categoryRepo: categoryRepo,
productVariantRepo: productVariantRepo,
inventoryRepo: inventoryRepo,
outletRepo: outletRepo,
outletPriceRepo: outletPriceRepo,
}
}
@@ -75,12 +80,12 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
}
}
exists, err := p.productRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, req.OrganizationID, req.OutletID, req.Name, nil)
if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this organization", req.Name)
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", req.Name)
}
productEntity := mappers.CreateProductRequestToEntity(req)
@@ -118,6 +123,18 @@ func (p *ProductProcessorImpl) CreateProduct(ctx context.Context, req *models.Cr
}
}
// Upsert outlet-specific price if outlet context is present
if req.OutletID != uuid.Nil {
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: productEntity.ID,
OutletID: req.OutletID,
Price: req.Price,
}
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, productEntity.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created product: %w", err)
@@ -157,12 +174,12 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
}
if req.Name != nil && *req.Name != existingProduct.Name {
exists, err := p.productRepo.ExistsByName(ctx, existingProduct.OrganizationID, *req.Name, &id)
exists, err := p.productRepo.ExistsByNameInOutlet(ctx, existingProduct.OrganizationID, req.OutletID, *req.Name, &id)
if err != nil {
return nil, fmt.Errorf("failed to check product name uniqueness: %w", err)
}
if exists {
return nil, fmt.Errorf("product with name '%s' already exists for this organization", *req.Name)
return nil, fmt.Errorf("product with name '%s' already exists for this outlet", *req.Name)
}
}
@@ -179,6 +196,18 @@ func (p *ProductProcessorImpl) UpdateProduct(ctx context.Context, id uuid.UUID,
}
}
// Upsert outlet-specific price if outlet context is present
if req.OutletID != uuid.Nil && req.Price != nil {
outletPriceEntity := &entities.ProductOutletPrice{
ProductID: id,
OutletID: req.OutletID,
Price: *req.Price,
}
if err := p.outletPriceRepo.Upsert(ctx, outletPriceEntity); err != nil {
return nil, fmt.Errorf("failed to assign outlet price: %w", err)
}
}
productWithCategory, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to retrieve updated product: %w", err)
@@ -214,19 +243,79 @@ func (p *ProductProcessorImpl) DeleteProduct(ctx context.Context, id uuid.UUID)
return nil
}
func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID) (*models.ProductResponse, error) {
func (p *ProductProcessorImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) (*models.ProductResponse, error) {
productEntity, err := p.productRepo.GetWithCategory(ctx, id)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
response := mappers.ProductEntityToResponse(productEntity)
if outletID != uuid.Nil {
// Attach outlet-specific price
outletPrice, err := p.outletPriceRepo.GetByProductAndOutlet(ctx, id, outletID)
if err == nil {
response.OutletPrice = &outletPrice.Price
}
} else {
// No outlet context — return all outlet prices for this product
outletPrices, err := p.outletPriceRepo.GetByProductWithOutlet(ctx, id)
if err == nil && len(outletPrices) > 0 {
prices := make([]models.OutletPrice, len(outletPrices))
for i, op := range outletPrices {
prices[i] = models.OutletPrice{
OutletID: op.OutletID,
OutletName: op.Outlet.Name,
Price: op.Price,
}
}
response.OutletPrices = prices
}
}
return response, nil
}
func (p *ProductProcessorImpl) ListProducts(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit
// Extract outletID from filters — it's not a products column so remove it before querying
var outletID uuid.UUID
if oid, ok := filters["outlet_id"]; ok {
outletID = oid.(uuid.UUID)
delete(filters, "outlet_id")
}
// Use the JOIN-based query when an outlet is specified so we get outlet-specific
// prices in a single round-trip; fall back to the plain List otherwise.
var (
productEntities []*entities.Product
total int64
err error
)
if outletID != uuid.Nil {
productEntities, total, err = p.productRepo.ListWithOutletPrice(ctx, filters, outletID, limit, offset)
} else {
productEntities, total, err = p.productRepo.List(ctx, filters, limit, offset)
}
if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err)
}
responses := make([]models.ProductResponse, len(productEntities))
for i, entity := range productEntities {
response := mappers.ProductEntityToResponse(entity)
if response != nil {
responses[i] = *response
}
}
return responses, int(total), nil
}
func (p *ProductProcessorImpl) ListProductsAll(ctx context.Context, filters map[string]interface{}, page, limit int) ([]models.ProductResponse, int, error) {
offset := (page - 1) * limit
productEntities, total, err := p.productRepo.List(ctx, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list products: %w", err)
+18
View File
@@ -4,6 +4,7 @@ import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/repository"
"context"
"errors"
@@ -207,6 +208,23 @@ func (p *TableProcessor) GetOccupiedTables(ctx context.Context, outletID uuid.UU
return responses, nil
}
func (p *TableProcessor) GetTokenByID(ctx context.Context, id uuid.UUID) (string, error) {
table, err := p.tableRepo.GetByID(ctx, id)
if err != nil {
return "", err
}
if _, _, _, err := tabletoken.Decode(table.Token); err != nil {
newToken := tabletoken.Encode(table.ID, table.OrganizationID, table.OutletID)
if updateErr := p.tableRepo.UpdateToken(ctx, table.ID, newToken); updateErr != nil {
return "", updateErr
}
return newToken, nil
}
return table.Token, nil
}
func (p *TableProcessor) mapTableToResponse(table *entities.Table) *models.TableResponse {
response := &models.TableResponse{
ID: table.ID,
+165
View File
@@ -0,0 +1,165 @@
package processor
import (
"context"
"fmt"
"time"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
type UserDeviceRepository interface {
Create(ctx context.Context, device *entities.UserDevice) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.UserDevice, error)
GetByDeviceID(ctx context.Context, deviceID string, userID uuid.UUID) (*entities.UserDevice, error)
GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entities.UserDevice, error)
Update(ctx context.Context, device *entities.UserDevice) error
Delete(ctx context.Context, id uuid.UUID) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.UserDevice, int64, error)
}
type UserDeviceProcessor interface {
RegisterDevice(ctx context.Context, req *models.RegisterUserDeviceRequest) (*models.UserDeviceResponse, error)
UpdateDevice(ctx context.Context, id uuid.UUID, req *models.UpdateUserDeviceRequest) (*models.UserDeviceResponse, error)
DeleteDevice(ctx context.Context, id uuid.UUID) error
GetDeviceByID(ctx context.Context, id uuid.UUID) (*models.UserDeviceResponse, error)
GetDevicesByUserID(ctx context.Context, userID uuid.UUID) ([]*models.UserDeviceResponse, error)
ListDevices(ctx context.Context, filters map[string]interface{}, page, limit int) ([]*models.UserDeviceResponse, int, error)
}
type UserDeviceProcessorImpl struct {
userDeviceRepo UserDeviceRepository
}
func NewUserDeviceProcessorImpl(userDeviceRepo UserDeviceRepository) *UserDeviceProcessorImpl {
return &UserDeviceProcessorImpl{
userDeviceRepo: userDeviceRepo,
}
}
func (p *UserDeviceProcessorImpl) RegisterDevice(ctx context.Context, req *models.RegisterUserDeviceRequest) (*models.UserDeviceResponse, error) {
// Upsert: if device already registered for this user, update it
existing, err := p.userDeviceRepo.GetByDeviceID(ctx, req.DeviceID, req.UserID)
if err == nil && existing != nil {
existing.DeviceName = req.DeviceName
existing.DeviceType = req.DeviceType
existing.Platform = req.Platform
existing.FCMToken = req.FCMToken
existing.AppVersion = req.AppVersion
existing.OsVersion = req.OsVersion
existing.IPAddress = req.IPAddress
now := time.Now()
existing.LastActiveAt = &now
if err := p.userDeviceRepo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("failed to update device: %w", err)
}
return mappers.UserDeviceEntityToResponse(existing), nil
}
deviceEntity := &entities.UserDevice{
UserID: req.UserID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
Platform: req.Platform,
FCMToken: req.FCMToken,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
IPAddress: req.IPAddress,
}
now := time.Now()
deviceEntity.LastActiveAt = &now
if err := p.userDeviceRepo.Create(ctx, deviceEntity); err != nil {
return nil, fmt.Errorf("failed to register device: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) UpdateDevice(ctx context.Context, id uuid.UUID, req *models.UpdateUserDeviceRequest) (*models.UserDeviceResponse, error) {
deviceEntity, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("device not found: %w", err)
}
if req.DeviceName != "" {
deviceEntity.DeviceName = req.DeviceName
}
if req.DeviceType != "" {
deviceEntity.DeviceType = req.DeviceType
}
if req.Platform != "" {
deviceEntity.Platform = req.Platform
}
if req.FCMToken != "" {
deviceEntity.FCMToken = req.FCMToken
}
if req.AppVersion != "" {
deviceEntity.AppVersion = req.AppVersion
}
if req.OsVersion != "" {
deviceEntity.OsVersion = req.OsVersion
}
now := time.Now()
deviceEntity.LastActiveAt = &now
if err := p.userDeviceRepo.Update(ctx, deviceEntity); err != nil {
return nil, fmt.Errorf("failed to update device: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) DeleteDevice(ctx context.Context, id uuid.UUID) error {
_, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("device not found: %w", err)
}
if err := p.userDeviceRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("failed to delete device: %w", err)
}
return nil
}
func (p *UserDeviceProcessorImpl) GetDeviceByID(ctx context.Context, id uuid.UUID) (*models.UserDeviceResponse, error) {
deviceEntity, err := p.userDeviceRepo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("device not found: %w", err)
}
return mappers.UserDeviceEntityToResponse(deviceEntity), nil
}
func (p *UserDeviceProcessorImpl) GetDevicesByUserID(ctx context.Context, userID uuid.UUID) ([]*models.UserDeviceResponse, error) {
deviceEntities, err := p.userDeviceRepo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get devices: %w", err)
}
return mappers.UserDeviceEntitiesToResponses(deviceEntities), nil
}
func (p *UserDeviceProcessorImpl) ListDevices(ctx context.Context, filters map[string]interface{}, page, limit int) ([]*models.UserDeviceResponse, int, error) {
offset := (page - 1) * limit
deviceEntities, total, err := p.userDeviceRepo.List(ctx, filters, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to list devices: %w", err)
}
deviceResponses := mappers.UserDeviceEntitiesToResponses(deviceEntities)
totalPages := int((total + int64(limit) - 1) / int64(limit))
return deviceResponses, totalPages, nil
}
+168 -21
View File
@@ -13,6 +13,7 @@ import (
type AnalyticsRepository interface {
GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error)
GetSalesAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]*entities.SalesAnalytics, error)
GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error)
GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error)
GetProductAnalyticsPerCategory(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.ProductAnalyticsPerCategory, error)
GetDashboardOverview(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.DashboardOverview, error)
@@ -29,6 +30,13 @@ func NewAnalyticsRepositoryImpl(db *gorm.DB) *AnalyticsRepositoryImpl {
}
}
func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid.UUID, column string) *gorm.DB {
if outletID != nil {
return query.Where(column+" = ?", *outletID)
}
return query
}
func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) ([]*entities.PaymentMethodAnalytics, error) {
var results []*entities.PaymentMethodAnalytics
@@ -50,9 +58,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
Where("p.status = ?", entities.PaymentTransactionStatusCompleted).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("pm.id, pm.name, pm.type").
@@ -117,6 +123,159 @@ func (r *AnalyticsRepositoryImpl) GetSalesAnalytics(ctx context.Context, organiz
return results, err
}
func (r *AnalyticsRepositoryImpl) GetPurchasingAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.PurchasingAnalytics, error) {
var summary entities.PurchasingSummary
var outletName *string
if outletID != nil {
var outlet struct {
Name string
}
result := r.db.WithContext(ctx).
Table("outlets").
Select("name").
Where("id = ? AND organization_id = ?", *outletID, organizationID).
Limit(1).
Scan(&outlet)
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected > 0 {
outletName = &outlet.Name
}
}
summaryQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
COALESCE(SUM(im.total_cost), 0) as total_purchases,
COUNT(DISTINCT im.reference_id) as total_purchase_orders,
COALESCE(SUM(im.quantity), 0) as total_quantity,
CASE
WHEN COUNT(DISTINCT im.reference_id) > 0
THEN COALESCE(SUM(im.total_cost), 0) / COUNT(DISTINCT im.reference_id)
ELSE 0
END as average_purchase_order_value,
COUNT(DISTINCT im.item_id) as total_ingredients,
COUNT(DISTINCT po.vendor_id) as total_vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo)
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "im.outlet_id")
if err := summaryQuery.Scan(&summary).Error; err != nil {
return nil, err
}
var dateFormat string
switch groupBy {
case "hour":
dateFormat = "DATE_TRUNC('hour', im.created_at)"
case "week":
dateFormat = "DATE_TRUNC('week', im.created_at)"
case "month":
dateFormat = "DATE_TRUNC('month', im.created_at)"
default:
dateFormat = "DATE_TRUNC('day', im.created_at)"
}
var data []entities.PurchasingAnalyticsData
dataQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
`+dateFormat+` as date,
COALESCE(SUM(im.total_cost), 0) as purchases,
COUNT(DISTINCT im.reference_id) as purchase_orders,
COALESCE(SUM(im.quantity), 0) as quantity,
COUNT(DISTINCT im.item_id) as ingredients,
COUNT(DISTINCT po.vendor_id) as vendors
`).
Joins("LEFT JOIN purchase_orders po ON im.reference_id = po.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group(dateFormat).
Order(dateFormat)
dataQuery = r.resolveOutletID(dataQuery, outletID, "im.outlet_id")
if err := dataQuery.Scan(&data).Error; err != nil {
return nil, err
}
var ingredientData []entities.PurchasingIngredientData
ingredientQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
i.id as ingredient_id,
i.name as ingredient_name,
COALESCE(SUM(im.quantity), 0) as quantity,
COALESCE(SUM(im.total_cost), 0) as total_cost,
CASE
WHEN SUM(im.quantity) > 0
THEN COALESCE(SUM(im.total_cost), 0) / SUM(im.quantity)
ELSE 0
END as average_unit_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count
`).
Joins("JOIN ingredients i ON im.item_id = i.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group("i.id, i.name").
Order("total_cost DESC")
ingredientQuery = r.resolveOutletID(ingredientQuery, outletID, "im.outlet_id")
if err := ingredientQuery.Scan(&ingredientData).Error; err != nil {
return nil, err
}
var vendorData []entities.PurchasingVendorData
vendorQuery := r.db.WithContext(ctx).
Table("inventory_movements im").
Select(`
v.id as vendor_id,
v.name as vendor_name,
COALESCE(SUM(im.total_cost), 0) as total_cost,
COUNT(DISTINCT im.reference_id) as purchase_order_count,
COUNT(DISTINCT im.item_id) as ingredient_count,
COALESCE(SUM(im.quantity), 0) as quantity
`).
Joins("JOIN purchase_orders po ON im.reference_id = po.id").
Joins("JOIN vendors v ON po.vendor_id = v.id").
Where("im.organization_id = ?", organizationID).
Where("im.movement_type = ?", entities.InventoryMovementTypePurchase).
Where("im.item_type = ?", "INGREDIENT").
Where("im.reference_type = ?", entities.InventoryMovementReferenceTypePurchaseOrder).
Where("im.created_at >= ? AND im.created_at <= ?", dateFrom, dateTo).
Group("v.id, v.name").
Order("total_cost DESC")
vendorQuery = r.resolveOutletID(vendorQuery, outletID, "im.outlet_id")
if err := vendorQuery.Scan(&vendorData).Error; err != nil {
return nil, err
}
return &entities.PurchasingAnalytics{
OutletName: outletName,
Summary: summary,
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
}, nil
}
func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, limit int) ([]*entities.ProductAnalytics, error) {
var results []*entities.ProductAnalytics
@@ -180,9 +339,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
Where("oi.status != ?", entities.OrderItemStatusCancelled).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("p.id, p.name, p.cost, c.id, c.name, c.order, mahpp.hpp_per_unit").
@@ -235,9 +392,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
Where("oi.status != ?", entities.OrderItemStatusCancelled).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.
Group("c.id, c.name").
@@ -267,9 +422,7 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
Where("o.organization_id = ?", organizationID).
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
if outletID != nil {
query = query.Where("o.outlet_id = ?", *outletID)
}
query = r.resolveOutletID(query, outletID, "o.outlet_id")
err := query.Scan(&result).Error
if err != nil {
@@ -320,9 +473,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Where("o.is_void = false AND o.is_refund = false").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
if outletID != nil {
summaryQuery = summaryQuery.Where("o.outlet_id = ?", *outletID)
}
summaryQuery = r.resolveOutletID(summaryQuery, outletID, "o.outlet_id")
err := summaryQuery.Scan(&summary).Error
if err != nil {
@@ -374,9 +525,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Group(timeFormat).
Order(timeFormat)
if outletID != nil {
dataQuery = dataQuery.Where("o.outlet_id = ?", *outletID)
}
dataQuery = r.resolveOutletID(dataQuery, outletID, "o.outlet_id")
err = dataQuery.Scan(&data).Error
if err != nil {
@@ -419,9 +568,7 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
Order("p.name ASC").
Limit(1000)
if outletID != nil {
productQuery = productQuery.Where("o.outlet_id = ?", *outletID)
}
productQuery = r.resolveOutletID(productQuery, outletID, "o.outlet_id")
err = productQuery.Scan(&productData).Error
if err != nil {
@@ -72,6 +72,9 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR description ILIKE ?", searchValue, searchValue)
case "outlet_id":
// Include outlet-specific categories AND global categories (outlet_id IS NULL)
query = query.Where("outlet_id = ? OR outlet_id IS NULL", value)
default:
query = query.Where(key+" = ?", value)
}
+18 -8
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type InventoryRepository interface {
@@ -278,7 +279,12 @@ func (r *InventoryRepositoryImpl) UpdateReorderLevel(ctx context.Context, id uui
}
func (r *InventoryRepositoryImpl) BulkCreate(ctx context.Context, inventoryItems []*entities.Inventory) error {
return r.db.WithContext(ctx).CreateInBatches(inventoryItems, 100).Error
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "outlet_id"}, {Name: "product_id"}},
DoNothing: true,
}).
CreateInBatches(inventoryItems, 100).Error
}
func (r *InventoryRepositoryImpl) BulkUpdate(ctx context.Context, inventoryItems []*entities.Inventory) error {
@@ -301,21 +307,25 @@ func (r *InventoryRepositoryImpl) BulkAdjustQuantity(ctx context.Context, adjust
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for productID, delta := range adjustments {
var inventory entities.Inventory
if err := tx.Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&inventory).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Inventory doesn't exist, create it with initial quantity
err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("product_id = ? AND outlet_id = ?", productID, outletID).
First(&inventory).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// Use FirstOrCreate to handle race conditions — avoids duplicate key
// if another transaction already inserted this row concurrently.
inventory = entities.Inventory{
ProductID: productID,
OutletID: outletID,
Quantity: 0,
ReorderLevel: 0,
}
if err := tx.Create(&inventory).Error; err != nil {
if err := tx.Where(entities.Inventory{ProductID: productID, OutletID: outletID}).
FirstOrCreate(&inventory).Error; err != nil {
return fmt.Errorf("failed to create inventory record for product %s: %w", productID, err)
}
} else {
return err
}
}
inventory.UpdateQuantity(delta)
@@ -0,0 +1,59 @@
package repository
import (
"context"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationDeliveryRepository interface {
Create(ctx context.Context, delivery *entities.NotificationDelivery) error
BulkCreate(ctx context.Context, deliveries []*entities.NotificationDelivery) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationDelivery, error)
Update(ctx context.Context, delivery *entities.NotificationDelivery) error
ListByReceiverID(ctx context.Context, receiverID uuid.UUID) ([]*entities.NotificationDelivery, error)
}
type NotificationDeliveryRepositoryImpl struct {
db *gorm.DB
}
func NewNotificationDeliveryRepository(db *gorm.DB) *NotificationDeliveryRepositoryImpl {
return &NotificationDeliveryRepositoryImpl{db: db}
}
func (r *NotificationDeliveryRepositoryImpl) Create(ctx context.Context, delivery *entities.NotificationDelivery) error {
return r.db.WithContext(ctx).Create(delivery).Error
}
func (r *NotificationDeliveryRepositoryImpl) BulkCreate(ctx context.Context, deliveries []*entities.NotificationDelivery) error {
if len(deliveries) == 0 {
return nil
}
return r.db.WithContext(ctx).Create(&deliveries).Error
}
func (r *NotificationDeliveryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationDelivery, error) {
var delivery entities.NotificationDelivery
err := r.db.WithContext(ctx).First(&delivery, "id = ?", id).Error
if err != nil {
return nil, err
}
return &delivery, nil
}
func (r *NotificationDeliveryRepositoryImpl) Update(ctx context.Context, delivery *entities.NotificationDelivery) error {
return r.db.WithContext(ctx).Save(delivery).Error
}
func (r *NotificationDeliveryRepositoryImpl) ListByReceiverID(ctx context.Context, receiverID uuid.UUID) ([]*entities.NotificationDelivery, error) {
var deliveries []*entities.NotificationDelivery
err := r.db.WithContext(ctx).
Where("notification_receiver_id = ?", receiverID).
Order("created_at DESC").
Find(&deliveries).Error
return deliveries, err
}
@@ -0,0 +1,108 @@
package repository
import (
"context"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationReceiverRepository interface {
Create(ctx context.Context, receiver *entities.NotificationReceiver) error
BulkCreate(ctx context.Context, receivers []*entities.NotificationReceiver) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationReceiver, error)
GetByNotificationAndUser(ctx context.Context, notificationID, userID uuid.UUID) (*entities.NotificationReceiver, error)
Update(ctx context.Context, receiver *entities.NotificationReceiver) error
ListByUserID(ctx context.Context, userID uuid.UUID, isRead *bool, limit, offset int) ([]*entities.NotificationReceiver, int64, error)
CountUnreadByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
SoftDeleteByID(ctx context.Context, id uuid.UUID) error
}
type NotificationReceiverRepositoryImpl struct {
db *gorm.DB
}
func NewNotificationReceiverRepository(db *gorm.DB) *NotificationReceiverRepositoryImpl {
return &NotificationReceiverRepositoryImpl{db: db}
}
func (r *NotificationReceiverRepositoryImpl) Create(ctx context.Context, receiver *entities.NotificationReceiver) error {
return r.db.WithContext(ctx).Create(receiver).Error
}
func (r *NotificationReceiverRepositoryImpl) BulkCreate(ctx context.Context, receivers []*entities.NotificationReceiver) error {
if len(receivers) == 0 {
return nil
}
return r.db.WithContext(ctx).Create(&receivers).Error
}
func (r *NotificationReceiverRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.NotificationReceiver, error) {
var receiver entities.NotificationReceiver
err := r.db.WithContext(ctx).
Preload("Notification").
First(&receiver, "id = ? AND is_deleted = false", id).Error
if err != nil {
return nil, err
}
return &receiver, nil
}
func (r *NotificationReceiverRepositoryImpl) GetByNotificationAndUser(ctx context.Context, notificationID, userID uuid.UUID) (*entities.NotificationReceiver, error) {
var receiver entities.NotificationReceiver
err := r.db.WithContext(ctx).
Where("notification_id = ? AND user_id = ? AND is_deleted = false", notificationID, userID).
First(&receiver).Error
if err != nil {
return nil, err
}
return &receiver, nil
}
func (r *NotificationReceiverRepositoryImpl) Update(ctx context.Context, receiver *entities.NotificationReceiver) error {
return r.db.WithContext(ctx).Save(receiver).Error
}
func (r *NotificationReceiverRepositoryImpl) ListByUserID(ctx context.Context, userID uuid.UUID, isRead *bool, limit, offset int) ([]*entities.NotificationReceiver, int64, error) {
var receivers []*entities.NotificationReceiver
var total int64
query := r.db.WithContext(ctx).
Model(&entities.NotificationReceiver{}).
Where("user_id = ? AND is_deleted = false", userID)
if isRead != nil {
query = query.Where("is_read = ?", *isRead)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.
Preload("Notification").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&receivers).Error
return receivers, total, err
}
func (r *NotificationReceiverRepositoryImpl) CountUnreadByUserID(ctx context.Context, userID uuid.UUID) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&entities.NotificationReceiver{}).
Where("user_id = ? AND is_read = false AND is_deleted = false", userID).
Count(&count).Error
return count, err
}
func (r *NotificationReceiverRepositoryImpl) SoftDeleteByID(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).
Model(&entities.NotificationReceiver{}).
Where("id = ?", id).
Updates(map[string]interface{}{"is_deleted": true}).Error
}
@@ -0,0 +1,64 @@
package repository
import (
"context"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationRepository interface {
Create(ctx context.Context, notification *entities.Notification) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Notification, error)
Update(ctx context.Context, notification *entities.Notification) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Notification, int64, error)
}
type NotificationRepositoryImpl struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *NotificationRepositoryImpl {
return &NotificationRepositoryImpl{db: db}
}
func (r *NotificationRepositoryImpl) Create(ctx context.Context, notification *entities.Notification) error {
return r.db.WithContext(ctx).Create(notification).Error
}
func (r *NotificationRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Notification, error) {
var notification entities.Notification
err := r.db.WithContext(ctx).First(&notification, "id = ?", id).Error
if err != nil {
return nil, err
}
return &notification, nil
}
func (r *NotificationRepositoryImpl) Update(ctx context.Context, notification *entities.Notification) error {
return r.db.WithContext(ctx).Save(notification).Error
}
func (r *NotificationRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.Notification{}, "id = ?", id).Error
}
func (r *NotificationRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Notification, int64, error) {
var notifications []*entities.Notification
var total int64
query := r.db.WithContext(ctx).Model(&entities.Notification{})
for key, value := range filters {
query = query.Where(key+" = ?", value)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&notifications).Error
return notifications, total, err
}
+57 -22
View File
@@ -18,6 +18,7 @@ type OrderRepository interface {
Update(ctx context.Context, order *entities.Order) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.Order, int64, error)
ListBySessionID(ctx context.Context, sessionID string) ([]*entities.Order, error)
GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error)
ExistsByOrderNumber(ctx context.Context, orderNumber string) (bool, error)
VoidOrder(ctx context.Context, id uuid.UUID, reason string, voidedBy uuid.UUID) error
@@ -97,7 +98,42 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
var orders []*entities.Order
var total int64
query := r.db.WithContext(ctx).Model(&entities.Order{}).
// organization_id is mandatory to prevent cross-org data leaks
organizationID, ok := filters["organization_id"]
if !ok {
return nil, 0, fmt.Errorf("organization_id is required for listing orders")
}
baseQuery := r.db.WithContext(ctx).Model(&entities.Order{}).
Where("organization_id = ?", organizationID)
// outlet_id is optional — if present, scope to that outlet; otherwise return all outlets in the org
if outletID, exists := filters["outlet_id"]; exists {
baseQuery = baseQuery.Where("outlet_id = ?", outletID)
}
for key, value := range filters {
switch key {
case "organization_id", "outlet_id":
// already handled above
case "search":
searchValue := "%" + value.(string) + "%"
baseQuery = baseQuery.Where("order_number ILIKE ?", searchValue)
case "date_from":
baseQuery = baseQuery.Where("created_at >= ?", value)
case "date_to":
baseQuery = baseQuery.Where("created_at <= ?", value)
default:
baseQuery = baseQuery.Where(key+" = ?", value)
}
}
// Use separate queries for count and find to avoid GORM state mutation issues
if err := baseQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
err := baseQuery.
Preload("Organization").
Preload("Outlet").
Preload("User").
@@ -106,30 +142,29 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
Preload("OrderItems.ProductVariant").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems")
for key, value := range filters {
switch key {
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("order_number ILIKE ?", searchValue)
case "date_from":
query = query.Where("created_at >= ?", value)
case "date_to":
query = query.Where("created_at <= ?", value)
default:
query = query.Where(key+" = ?", value)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Limit(limit).Offset(offset).Order("created_at DESC").Find(&orders).Error
Preload("Payments.PaymentOrderItems").
Limit(limit).Offset(offset).Order("created_at DESC").Find(&orders).Error
return orders, total, err
}
func (r *OrderRepositoryImpl) ListBySessionID(ctx context.Context, sessionID string) ([]*entities.Order, error) {
var orders []*entities.Order
err := r.db.WithContext(ctx).Model(&entities.Order{}).
Preload("Organization").
Preload("Outlet").
Preload("User").
Preload("OrderItems").
Preload("OrderItems.Product").
Preload("OrderItems.ProductVariant").
Preload("Payments").
Preload("Payments.PaymentMethod").
Preload("Payments.PaymentOrderItems").
Where("metadata->>'session_id' = ?", sessionID).
Order("created_at ASC").
Find(&orders).Error
return orders, err
}
func (r *OrderRepositoryImpl) GetByOrderNumber(ctx context.Context, orderNumber string) (*entities.Order, error) {
var order entities.Order
err := r.db.WithContext(ctx).First(&order, "order_number = ?", orderNumber).Error
@@ -99,3 +99,14 @@ func (r *OrganizationRepositoryImpl) GetByEmail(ctx context.Context, email strin
}
return &org, nil
}
// GetTotalOmset returns the total revenue from completed orders for an organization.
func (r *OrganizationRepositoryImpl) GetTotalOmset(ctx context.Context, organizationID uuid.UUID) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("orders").
Where("organization_id = ? AND payment_status = ? AND is_void = ? AND is_refund = ?", organizationID, "completed", false, false).
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return total, err
}
@@ -0,0 +1,85 @@
package repository
import (
"context"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ProductOutletPriceRepository interface {
GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*entities.ProductOutletPrice, error)
GetByProduct(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error)
GetByProductWithOutlet(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error)
GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error)
GetByProductsAndOutlet(ctx context.Context, productIDs []uuid.UUID, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error)
Upsert(ctx context.Context, price *entities.ProductOutletPrice) error
Delete(ctx context.Context, id uuid.UUID) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.ProductOutletPrice, error)
}
type ProductOutletPriceRepositoryImpl struct {
db *gorm.DB
}
func NewProductOutletPriceRepositoryImpl(db *gorm.DB) *ProductOutletPriceRepositoryImpl {
return &ProductOutletPriceRepositoryImpl{
db: db,
}
}
func (r *ProductOutletPriceRepositoryImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) (*entities.ProductOutletPrice, error) {
var price entities.ProductOutletPrice
err := r.db.WithContext(ctx).Where("product_id = ? AND outlet_id = ?", productID, outletID).First(&price).Error
if err != nil {
return nil, err
}
return &price, nil
}
func (r *ProductOutletPriceRepositoryImpl) GetByProduct(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error) {
var prices []*entities.ProductOutletPrice
err := r.db.WithContext(ctx).Where("product_id = ?", productID).Find(&prices).Error
return prices, err
}
func (r *ProductOutletPriceRepositoryImpl) GetByOutlet(ctx context.Context, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error) {
var prices []*entities.ProductOutletPrice
err := r.db.WithContext(ctx).Where("outlet_id = ?", outletID).Find(&prices).Error
return prices, err
}
func (r *ProductOutletPriceRepositoryImpl) Upsert(ctx context.Context, price *entities.ProductOutletPrice) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "product_id"}, {Name: "outlet_id"}},
DoUpdates: clause.AssignmentColumns([]string{"price", "updated_at"}),
}).Create(price).Error
}
func (r *ProductOutletPriceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.ProductOutletPrice{}, "id = ?", id).Error
}
func (r *ProductOutletPriceRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.ProductOutletPrice, error) {
var price entities.ProductOutletPrice
err := r.db.WithContext(ctx).First(&price, "id = ?", id).Error
if err != nil {
return nil, err
}
return &price, nil
}
func (r *ProductOutletPriceRepositoryImpl) GetByProductsAndOutlet(ctx context.Context, productIDs []uuid.UUID, outletID uuid.UUID) ([]*entities.ProductOutletPrice, error) {
var prices []*entities.ProductOutletPrice
err := r.db.WithContext(ctx).Where("product_id IN ? AND outlet_id = ?", productIDs, outletID).Find(&prices).Error
return prices, err
}
func (r *ProductOutletPriceRepositoryImpl) GetByProductWithOutlet(ctx context.Context, productID uuid.UUID) ([]*entities.ProductOutletPrice, error) {
var prices []*entities.ProductOutletPrice
err := r.db.WithContext(ctx).Preload("Outlet").Where("product_id = ?", productID).Find(&prices).Error
return prices, err
}
+64
View File
@@ -178,6 +178,26 @@ func (r *ProductRepositoryImpl) ExistsByName(ctx context.Context, organizationID
return count > 0, err
}
// ExistsByNameInOutlet checks name uniqueness scoped to a specific outlet via product_outlet_prices.
// Falls back to organization-scoped check when outletID is zero.
func (r *ProductRepositoryImpl) ExistsByNameInOutlet(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID, name string, excludeID *uuid.UUID) (bool, error) {
if outletID == uuid.Nil {
return r.ExistsByName(ctx, organizationID, name, excludeID)
}
query := r.db.WithContext(ctx).Model(&entities.Product{}).
Joins("INNER JOIN product_outlet_prices pop ON pop.product_id = products.id AND pop.outlet_id = ?", outletID).
Where("products.organization_id = ? AND products.name = ?", organizationID, name)
if excludeID != nil {
query = query.Where("products.id != ?", *excludeID)
}
var count int64
err := query.Count(&count).Error
return count > 0, err
}
func (r *ProductRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error {
return r.db.WithContext(ctx).Model(&entities.Product{}).
Where("id = ?", id).
@@ -189,3 +209,47 @@ func (r *ProductRepositoryImpl) GetLowCostProducts(ctx context.Context, organiza
err := r.db.WithContext(ctx).Where("organization_id = ? AND cost <= ? AND is_active = ?", organizationID, maxCost, true).Find(&products).Error
return products, err
}
// ListWithOutletPrice fetches products with the same filters as List, but overrides
// each product's Price with the outlet-specific price from product_outlet_prices when
// outletID is provided. A single LEFT JOIN is used so no second round-trip is needed.
func (r *ProductRepositoryImpl) ListWithOutletPrice(ctx context.Context, filters map[string]interface{}, outletID uuid.UUID, limit, offset int) ([]*entities.Product, int64, error) {
var products []*entities.Product
var total int64
// Base query with category and variant preloads
query := r.db.WithContext(ctx).Model(&entities.Product{}).
Preload("Category").
Preload("ProductVariants")
// Apply filters
for key, value := range filters {
switch key {
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("products.name ILIKE ? OR products.description ILIKE ? OR products.sku ILIKE ?", searchValue, searchValue, searchValue)
case "price_min":
query = query.Where("products.price >= ?", value)
case "price_max":
query = query.Where("products.price <= ?", value)
default:
query = query.Where("products."+key+" = ?", value)
}
}
// When outletID is provided, INNER JOIN product_outlet_prices so only products
// that have been explicitly assigned to this outlet are returned, with their
// outlet-specific price.
if outletID != uuid.Nil {
query = query.
Joins("INNER JOIN product_outlet_prices pop ON pop.product_id = products.id AND pop.outlet_id = ?", outletID).
Select("products.*, pop.price AS price")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Limit(limit).Offset(offset).Find(&products).Error
return products, total, err
}
+141
View File
@@ -0,0 +1,141 @@
package repository
import (
"apskel-pos-be/internal/models"
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
const (
sessionKeyPrefix = "self_order:session:"
tableSessionKeyPrefix = "self_order:table_session:"
sessionTTL = 24 * time.Hour
sessionStatusActive = "active"
sessionStatusClosed = "closed"
)
type SessionRepository interface {
Create(ctx context.Context, session *models.SelfOrderSession) error
GetByID(ctx context.Context, sessionID string) (*models.SelfOrderSession, error)
GetActiveByTableID(ctx context.Context, tableID uuid.UUID) (*models.SelfOrderSession, error)
Close(ctx context.Context, sessionID string) error
CloseByTableID(ctx context.Context, tableID uuid.UUID) error
}
type sessionRepository struct {
client *redis.Client
}
func NewSessionRepository(client *redis.Client) SessionRepository {
return &sessionRepository{client: client}
}
func (r *sessionRepository) Create(ctx context.Context, session *models.SelfOrderSession) error {
if session.ID == "" {
session.ID = uuid.New().String()
}
session.Status = sessionStatusActive
session.CreatedAt = time.Now()
data, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
sessionKey := sessionKeyPrefix + session.ID
tableSessionKey := tableSessionKeyPrefix + session.TableID.String()
pipe := r.client.Pipeline()
pipe.Set(ctx, sessionKey, data, sessionTTL)
pipe.Set(ctx, tableSessionKey, session.ID, sessionTTL)
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("failed to store session in redis: %w", err)
}
return nil
}
func (r *sessionRepository) GetByID(ctx context.Context, sessionID string) (*models.SelfOrderSession, error) {
data, err := r.client.Get(ctx, sessionKeyPrefix+sessionID).Bytes()
if err != nil {
if err == redis.Nil {
return nil, nil
}
return nil, fmt.Errorf("failed to get session: %w", err)
}
var session models.SelfOrderSession
if err := json.Unmarshal(data, &session); err != nil {
return nil, fmt.Errorf("failed to unmarshal session: %w", err)
}
return &session, nil
}
func (r *sessionRepository) GetActiveByTableID(ctx context.Context, tableID uuid.UUID) (*models.SelfOrderSession, error) {
sessionID, err := r.client.Get(ctx, tableSessionKeyPrefix+tableID.String()).Result()
if err != nil {
if err == redis.Nil {
return nil, nil
}
return nil, fmt.Errorf("failed to get session for table: %w", err)
}
session, err := r.GetByID(ctx, sessionID)
if err != nil {
return nil, err
}
if session != nil && session.Status != sessionStatusActive {
return nil, nil
}
return session, nil
}
func (r *sessionRepository) Close(ctx context.Context, sessionID string) error {
session, err := r.GetByID(ctx, sessionID)
if err != nil {
return err
}
if session == nil {
return fmt.Errorf("session not found")
}
now := time.Now()
session.Status = sessionStatusClosed
session.ClosedAt = &now
data, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("failed to marshal session: %w", err)
}
pipe := r.client.Pipeline()
pipe.Set(ctx, sessionKeyPrefix+session.ID, data, sessionTTL)
pipe.Del(ctx, tableSessionKeyPrefix+session.TableID.String())
if _, err := pipe.Exec(ctx); err != nil {
return fmt.Errorf("failed to close session: %w", err)
}
return nil
}
func (r *sessionRepository) CloseByTableID(ctx context.Context, tableID uuid.UUID) error {
session, err := r.GetActiveByTableID(ctx, tableID)
if err != nil {
return err
}
if session == nil {
return nil
}
return r.Close(ctx, session.ID)
}
+21
View File
@@ -36,6 +36,20 @@ func (r *TableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.
return &table, nil
}
func (r *TableRepository) GetByToken(ctx context.Context, token string) (*entities.Table, error) {
var table entities.Table
err := r.db.WithContext(ctx).
Preload("Organization").
Preload("Outlet").
Preload("Order").
Where("token = ?", token).
First(&table).Error
if err != nil {
return nil, err
}
return &table, nil
}
func (r *TableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) {
var tables []entities.Table
err := r.db.WithContext(ctx).
@@ -157,6 +171,13 @@ func (r *TableRepository) ReleaseTable(ctx context.Context, tableID uuid.UUID, p
}).Error
}
func (r *TableRepository) UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error {
return r.db.WithContext(ctx).
Model(&entities.Table{}).
Where("id = ?", tableID).
Update("token", token).Error
}
func (r *TableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error) {
var table entities.Table
err := r.db.WithContext(ctx).
@@ -13,6 +13,7 @@ import (
type TableRepositoryInterface interface {
Create(ctx context.Context, table *entities.Table) error
GetByID(ctx context.Context, id uuid.UUID) (*entities.Table, error)
GetByToken(ctx context.Context, token string) (*entities.Table, error)
GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error)
GetByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]entities.Table, error)
Update(ctx context.Context, table *entities.Table) error
@@ -23,4 +24,5 @@ type TableRepositoryInterface interface {
OccupyTable(ctx context.Context, tableID, orderID uuid.UUID, startTime *time.Time) error
ReleaseTable(ctx context.Context, tableID uuid.UUID, paymentAmount float64) error
GetByOrderID(ctx context.Context, orderID uuid.UUID) (*entities.Table, error)
UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error
}
+4
View File
@@ -28,6 +28,10 @@ func NewTxManager(db *gorm.DB) *TxManager { return &TxManager{db: db} }
// WithTransaction runs fn inside a DB transaction, injecting the *gorm.DB tx into ctx.
func (m *TxManager) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
if m == nil || m.db == nil {
return fn(ctx)
}
return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
ctxTx := context.WithValue(ctx, txKey, tx)
return fn(ctxTx)
@@ -0,0 +1,94 @@
package repository
import (
"context"
"strings"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type UserDeviceRepositoryImpl struct {
db *gorm.DB
}
func NewUserDeviceRepositoryImpl(db *gorm.DB) *UserDeviceRepositoryImpl {
return &UserDeviceRepositoryImpl{
db: db,
}
}
func (r *UserDeviceRepositoryImpl) Create(ctx context.Context, device *entities.UserDevice) error {
return r.db.WithContext(ctx).Create(device).Error
}
func (r *UserDeviceRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.UserDevice, error) {
var device entities.UserDevice
err := r.db.WithContext(ctx).First(&device, "id = ?", id).Error
if err != nil {
return nil, err
}
return &device, nil
}
func (r *UserDeviceRepositoryImpl) GetByDeviceID(ctx context.Context, deviceID string, userID uuid.UUID) (*entities.UserDevice, error) {
var device entities.UserDevice
err := r.db.WithContext(ctx).Where("device_id = ? AND user_id = ?", deviceID, userID).First(&device).Error
if err != nil {
return nil, err
}
return &device, nil
}
func (r *UserDeviceRepositoryImpl) GetByUserID(ctx context.Context, userID uuid.UUID) ([]*entities.UserDevice, error) {
var devices []*entities.UserDevice
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Order("created_at DESC").Find(&devices).Error
return devices, err
}
func (r *UserDeviceRepositoryImpl) Update(ctx context.Context, device *entities.UserDevice) error {
return r.db.WithContext(ctx).Save(device).Error
}
func (r *UserDeviceRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.UserDevice{}, "id = ?", id).Error
}
func (r *UserDeviceRepositoryImpl) DeleteByUserID(ctx context.Context, userID uuid.UUID) error {
return r.db.WithContext(ctx).Delete(&entities.UserDevice{}, "user_id = ?", userID).Error
}
func (r *UserDeviceRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.UserDevice, int64, error) {
var devices []*entities.UserDevice
var total int64
query := r.db.WithContext(ctx).Model(&entities.UserDevice{})
for key, value := range filters {
switch key {
case "user_id":
query = query.Where("user_id = ?", value)
case "platform":
if platform, ok := value.(string); ok && platform != "" {
query = query.Where("platform = ?", platform)
}
case "search":
if searchStr, ok := value.(string); ok && searchStr != "" {
searchPattern := "%" + strings.ToLower(searchStr) + "%"
query = query.Where("LOWER(device_name) LIKE ? OR LOWER(device_id) LIKE ?",
searchPattern, searchPattern)
}
default:
query = query.Where(key+" = ?", value)
}
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&devices).Error
return devices, total, err
}
+11
View File
@@ -61,6 +61,17 @@ func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID
return users, err
}
func (r *UserRepositoryImpl) GetActiveByOutletID(ctx context.Context, organizationID, outletID uuid.UUID) ([]*entities.User, error) {
var users []*entities.User
err := r.db.WithContext(ctx).
Where(
"organization_id = ? AND is_active = ? AND (outlet_id = ? OR role IN ?)",
organizationID, true, outletID, []string{"admin", "manager"},
).
Find(&users).Error
return users, err
}
func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
+69 -2
View File
@@ -46,11 +46,15 @@ type Router struct {
customerAuthHandler *handler.CustomerAuthHandler
customerPointsHandler *handler.CustomerPointsHandler
spinGameHandler *handler.SpinGameHandler
userDeviceHandler *handler.UserDeviceHandler
notificationHandler *handler.NotificationHandler
selfOrderHandler *handler.SelfOrderHandler
productOutletPriceHandler *handler.ProductOutletPriceHandler
authMiddleware *middleware.AuthMiddleware
customerAuthMiddleware *middleware.CustomerAuthMiddleware
}
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router {
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler) *Router {
return &Router{
config: cfg,
@@ -69,7 +73,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
paymentMethodHandler: handler.NewPaymentMethodHandler(paymentMethodService, paymentMethodValidator),
analyticsHandler: handler.NewAnalyticsHandler(analyticsService, transformer.NewTransformer()),
reportHandler: handler.NewReportHandler(reportService, userService),
tableHandler: handler.NewTableHandler(tableService, tableValidator),
tableHandler: handler.NewTableHandler(tableService, tableValidator, cfg.Server.SelfOrderUrl),
unitHandler: handler.NewUnitHandler(unitService),
ingredientHandler: handler.NewIngredientHandler(ingredientService),
productRecipeHandler: handler.NewProductRecipeHandler(productRecipeService),
@@ -89,6 +93,10 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
authMiddleware: authMiddleware,
customerAuthMiddleware: customerAuthMiddleware,
productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator),
userDeviceHandler: handler.NewUserDeviceHandler(userDeviceService, userDeviceValidator),
notificationHandler: handler.NewNotificationHandler(notificationService, notificationValidator),
selfOrderHandler: selfOrderHandler,
productOutletPriceHandler: handler.NewProductOutletPriceHandler(productOutletPriceService, productOutletPriceValidator),
}
}
@@ -145,6 +153,15 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.POST("/spin", r.spinGameHandler.PlaySpinGame)
}
selfOrder := v1.Group("/self-order")
{
selfOrder.GET("/table/:token", r.selfOrderHandler.ValidateToken)
selfOrder.GET("/categories", r.selfOrderHandler.ListCategories)
selfOrder.GET("/menu", r.selfOrderHandler.GetMenu)
selfOrder.POST("/orders", r.selfOrderHandler.CreateOrder)
selfOrder.GET("/orders/:session_id", r.selfOrderHandler.GetOrdersBySession)
}
organizations := v1.Group("/organizations")
{
organizations.POST("", r.organizationHandler.CreateOrganization)
@@ -208,11 +225,23 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
products.POST("", r.productHandler.CreateProduct)
products.GET("", r.productHandler.ListProducts)
products.GET("/all", r.productHandler.ListProductAll)
products.GET("/:id", r.productHandler.GetProduct)
products.PUT("/:id", r.productHandler.UpdateProduct)
products.DELETE("/:id", r.productHandler.DeleteProduct)
}
productOutletPrices := protected.Group("/product-outlet-prices")
productOutletPrices.Use(r.authMiddleware.RequireAdminOrManager())
{
productOutletPrices.POST("", r.productOutletPriceHandler.Upsert)
productOutletPrices.POST("/bulk", r.productOutletPriceHandler.BulkUpsert)
productOutletPrices.GET("/product/:product_id", r.productOutletPriceHandler.GetByProduct)
productOutletPrices.GET("/outlet/:outlet_id", r.productOutletPriceHandler.GetByOutlet)
productOutletPrices.GET("/product/:product_id/outlet/:outlet_id", r.productOutletPriceHandler.GetByProductAndOutlet)
productOutletPrices.DELETE("/:id", r.productOutletPriceHandler.Delete)
}
productVariants := protected.Group("/product-variants")
{
productVariants.POST("", r.productVariantHandler.CreateProductVariant)
@@ -296,6 +325,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
analytics.GET("/payment-methods", r.analyticsHandler.GetPaymentMethodAnalytics)
analytics.GET("/sales", r.analyticsHandler.GetSalesAnalytics)
analytics.GET("/purchasing", r.analyticsHandler.GetPurchasingAnalytics)
analytics.GET("/products", r.analyticsHandler.GetProductAnalytics)
analytics.GET("/categories", r.analyticsHandler.GetProductAnalyticsPerCategory)
analytics.GET("/dashboard", r.analyticsHandler.GetDashboardAnalytics)
@@ -312,6 +342,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
tables.DELETE("/:id", r.tableHandler.Delete)
tables.POST("/:id/occupy", r.tableHandler.OccupyTable)
tables.POST("/:id/release", r.tableHandler.ReleaseTable)
tables.GET("/:id/qr", r.tableHandler.GenerateQRCode)
}
ingredients := protected.Group("/ingredients")
@@ -559,6 +590,42 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
// Reports
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
}
// User device routes - accessible by authenticated users for their own devices
userDevices := protected.Group("/user-devices")
{
userDevices.POST("/register", r.userDeviceHandler.RegisterDevice)
userDevices.GET("/me", r.userDeviceHandler.GetMyDevices)
userDevices.GET("/:id", r.userDeviceHandler.GetDevice)
userDevices.PUT("/:id", r.userDeviceHandler.UpdateDevice)
userDevices.DELETE("/:id", r.userDeviceHandler.DeleteDevice)
}
// Admin-only user device routes
adminUserDevices := protected.Group("/user-devices")
adminUserDevices.Use(r.authMiddleware.RequireAdminOrManager())
{
adminUserDevices.GET("", r.userDeviceHandler.ListDevices)
adminUserDevices.GET("/user/:user_id", r.userDeviceHandler.GetDevicesByUser)
}
// Notification routes - authenticated users manage their own notifications
notifications := protected.Group("/notifications")
{
notifications.GET("", r.notificationHandler.List)
notifications.GET("/:id", r.notificationHandler.GetByID)
notifications.PUT("/:id/read", r.notificationHandler.MarkAsRead)
notifications.PUT("/read-all", r.notificationHandler.MarkAllAsRead)
notifications.DELETE("/:id", r.notificationHandler.Delete)
}
// Admin notification routes - send and broadcast
adminNotifications := protected.Group("/notifications")
adminNotifications.Use(r.authMiddleware.RequireAdminOrManager())
{
adminNotifications.POST("/send", r.notificationHandler.Send)
adminNotifications.POST("/broadcast", r.notificationHandler.Broadcast)
}
}
}
}
+50
View File
@@ -13,6 +13,7 @@ import (
type AnalyticsService interface {
GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error)
GetSalesAnalytics(ctx context.Context, req *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error)
GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error)
GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error)
GetProductAnalyticsPerCategory(ctx context.Context, req *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error)
GetDashboardAnalytics(ctx context.Context, req *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error)
@@ -57,6 +58,19 @@ func (s *AnalyticsServiceImpl) GetSalesAnalytics(ctx context.Context, req *model
return response, nil
}
func (s *AnalyticsServiceImpl) GetPurchasingAnalytics(ctx context.Context, req *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
if err := s.validatePurchasingAnalyticsRequest(req); err != nil {
return nil, fmt.Errorf("validation error: %w", err)
}
response, err := s.analyticsProcessor.GetPurchasingAnalytics(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to get purchasing analytics: %w", err)
}
return response, nil
}
func (s *AnalyticsServiceImpl) GetProductAnalytics(ctx context.Context, req *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
// Validate request
if err := s.validateProductAnalyticsRequest(req); err != nil {
@@ -168,6 +182,42 @@ func (s *AnalyticsServiceImpl) validateSalesAnalyticsRequest(req *models.SalesAn
return nil
}
func (s *AnalyticsServiceImpl) validatePurchasingAnalyticsRequest(req *models.PurchasingAnalyticsRequest) error {
if req == nil {
return fmt.Errorf("request cannot be nil")
}
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
}
if req.DateFrom.IsZero() {
return fmt.Errorf("date_from is required")
}
if req.DateTo.IsZero() {
return fmt.Errorf("date_to is required")
}
if req.DateFrom.After(req.DateTo) {
return fmt.Errorf("date_from cannot be after date_to")
}
if req.GroupBy != "" {
validGroupBy := map[string]bool{
"day": true,
"hour": true,
"week": true,
"month": true,
}
if !validGroupBy[req.GroupBy] {
return fmt.Errorf("invalid group_by value: %s", req.GroupBy)
}
}
return nil
}
func (s *AnalyticsServiceImpl) validateProductAnalyticsRequest(req *models.ProductAnalyticsRequest) error {
if req.OrganizationID == uuid.Nil {
return fmt.Errorf("organization ID is required")
+121
View File
@@ -0,0 +1,121 @@
package service
import (
"context"
"testing"
"time"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
type analyticsProcessorStub struct{}
func (analyticsProcessorStub) GetPaymentMethodAnalytics(context.Context, *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetSalesAnalytics(context.Context, *models.SalesAnalyticsRequest) (*models.SalesAnalyticsResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetPurchasingAnalytics(context.Context, *models.PurchasingAnalyticsRequest) (*models.PurchasingAnalyticsResponse, error) {
return &models.PurchasingAnalyticsResponse{}, nil
}
func (analyticsProcessorStub) GetProductAnalytics(context.Context, *models.ProductAnalyticsRequest) (*models.ProductAnalyticsResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetProductAnalyticsPerCategory(context.Context, *models.ProductAnalyticsPerCategoryRequest) (*models.ProductAnalyticsPerCategoryResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetDashboardAnalytics(context.Context, *models.DashboardAnalyticsRequest) (*models.DashboardAnalyticsResponse, error) {
return nil, nil
}
func (analyticsProcessorStub) GetProfitLossAnalytics(context.Context, *models.ProfitLossAnalyticsRequest) (*models.ProfitLossAnalyticsResponse, error) {
return nil, nil
}
func TestAnalyticsServiceGetPurchasingAnalyticsValidation(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
tests := []struct {
name string
req *models.PurchasingAnalyticsRequest
wantErr string
}{
{
name: "missing organization",
req: &models.PurchasingAnalyticsRequest{
DateFrom: now,
DateTo: now,
},
wantErr: "organization ID is required",
},
{
name: "missing date_from",
req: &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateTo: now,
},
wantErr: "date_from is required",
},
{
name: "missing date_to",
req: &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
},
wantErr: "date_to is required",
},
{
name: "reversed dates",
req: &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now.AddDate(0, 0, 1),
DateTo: now,
},
wantErr: "date_from cannot be after date_to",
},
{
name: "invalid group_by",
req: &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
GroupBy: "quarter",
},
wantErr: "invalid group_by value: quarter",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := service.GetPurchasingAnalytics(context.Background(), tt.req)
require.Nil(t, resp)
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestAnalyticsServiceGetPurchasingAnalyticsAllowsEmptyGroupBy(t *testing.T) {
service := NewAnalyticsServiceImpl(analyticsProcessorStub{})
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
resp, err := service.GetPurchasingAnalytics(context.Background(), &models.PurchasingAnalyticsRequest{
OrganizationID: uuid.New(),
DateFrom: now,
DateTo: now,
})
require.NoError(t, err)
require.NotNil(t, resp)
}
+24 -1
View File
@@ -8,7 +8,9 @@ import (
"apskel-pos-be/config"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"github.com/golang-jwt/jwt/v5"
@@ -25,6 +27,7 @@ type AuthService interface {
type AuthServiceImpl struct {
userProcessor UserProcessor
userDeviceProcessor processor.UserDeviceProcessor
jwtSecret string
refreshSecret string
tokenTTL time.Duration
@@ -39,9 +42,10 @@ type Claims struct {
jwt.RegisteredClaims
}
func NewAuthService(userProcessor UserProcessor, authConfig *config.AuthConfig) AuthService {
func NewAuthService(userProcessor UserProcessor, userDeviceProcessor processor.UserDeviceProcessor, authConfig *config.AuthConfig) AuthService {
return &AuthServiceImpl{
userProcessor: userProcessor,
userDeviceProcessor: userDeviceProcessor,
jwtSecret: authConfig.AccessTokenSecret(),
refreshSecret: authConfig.RefreshTokenSecret(),
tokenTTL: authConfig.AccessTokenTTL(),
@@ -81,6 +85,25 @@ func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest)
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
// Register or update device info if provided
if req.DeviceID != "" && s.userDeviceProcessor != nil {
deviceReq := &models.RegisterUserDeviceRequest{
UserID: userResponse.ID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
DeviceType: entities.DeviceType(req.DeviceType),
Platform: entities.DevicePlatform(req.Platform),
FCMToken: req.FCMToken,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
}
// Non-blocking: log error but don't fail login
if _, err := s.userDeviceProcessor.RegisterDevice(ctx, deviceReq); err != nil {
// Log but don't fail the login
_ = err
}
}
return &contract.LoginResponse{
Token: token,
RefreshToken: refreshToken,
+3
View File
@@ -85,6 +85,9 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
if req.OrganizationID != nil {
filters["organization_id"] = *req.OrganizationID
}
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.BusinessType != "" {
filters["business_type"] = req.BusinessType
}
+154
View File
@@ -0,0 +1,154 @@
package service
import (
"context"
"math"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"github.com/google/uuid"
)
type NotificationService interface {
Send(ctx context.Context, req *contract.SendNotificationRequest, createdBy uuid.UUID) *contract.Response
Broadcast(ctx context.Context, req *contract.BroadcastNotificationRequest, organizationID, createdBy uuid.UUID) *contract.Response
MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) *contract.Response
MarkAllAsRead(ctx context.Context, userID uuid.UUID) *contract.Response
DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) *contract.Response
ListForUser(ctx context.Context, req *contract.ListNotificationsRequest, userID uuid.UUID) *contract.Response
GetByID(ctx context.Context, id uuid.UUID) *contract.Response
}
type NotificationServiceImpl struct {
notificationProcessor processor.NotificationProcessor
}
func NewNotificationService(notificationProcessor processor.NotificationProcessor) *NotificationServiceImpl {
return &NotificationServiceImpl{
notificationProcessor: notificationProcessor,
}
}
func (s *NotificationServiceImpl) Send(ctx context.Context, req *contract.SendNotificationRequest, createdBy uuid.UUID) *contract.Response {
modelReq := &models.SendNotificationRequest{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
ReceiverIDs: req.ReceiverIDs,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: &createdBy,
}
resp, err := s.notificationProcessor.Send(ctx, modelReq)
if err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(transformer.NotificationModelResponseToContract(resp))
}
func (s *NotificationServiceImpl) Broadcast(ctx context.Context, req *contract.BroadcastNotificationRequest, organizationID, createdBy uuid.UUID) *contract.Response {
modelReq := &models.BroadcastNotificationRequest{
Title: req.Title,
Body: req.Body,
Type: req.Type,
Category: req.Category,
Priority: req.Priority,
ImageURL: req.ImageURL,
ActionURL: req.ActionURL,
NotifiableType: req.NotifiableType,
NotifiableID: req.NotifiableID,
Data: req.Data,
OrganizationID: organizationID,
ScheduledAt: req.ScheduledAt,
ExpiredAt: req.ExpiredAt,
CreatedBy: &createdBy,
}
resp, err := s.notificationProcessor.Broadcast(ctx, modelReq)
if err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(transformer.NotificationModelResponseToContract(resp))
}
func (s *NotificationServiceImpl) MarkAsRead(ctx context.Context, receiverID, userID uuid.UUID) *contract.Response {
resp, err := s.notificationProcessor.MarkAsRead(ctx, receiverID, userID)
if err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(transformer.NotificationReceiverModelResponseToContract(resp))
}
func (s *NotificationServiceImpl) MarkAllAsRead(ctx context.Context, userID uuid.UUID) *contract.Response {
if err := s.notificationProcessor.MarkAllAsRead(ctx, userID); err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{"message": "All notifications marked as read"})
}
func (s *NotificationServiceImpl) DeleteForUser(ctx context.Context, receiverID, userID uuid.UUID) *contract.Response {
if err := s.notificationProcessor.DeleteForUser(ctx, receiverID, userID); err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{"message": "Notification deleted"})
}
func (s *NotificationServiceImpl) ListForUser(ctx context.Context, req *contract.ListNotificationsRequest, userID uuid.UUID) *contract.Response {
modelReq := &models.ListNotificationsRequest{
Page: req.Page,
Limit: req.Limit,
UserID: userID,
IsRead: req.IsRead,
}
receivers, total, unreadCount, err := s.notificationProcessor.ListForUser(ctx, modelReq)
if err != nil {
errResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
totalPages := int(math.Ceil(float64(total) / float64(req.Limit)))
response := contract.ListNotificationsResponse{
Notifications: transformer.NotificationReceiverModelResponsesToContracts(receivers),
TotalCount: total,
UnreadCount: unreadCount,
Page: req.Page,
Limit: req.Limit,
TotalPages: totalPages,
}
return contract.BuildSuccessResponse(response)
}
func (s *NotificationServiceImpl) GetByID(ctx context.Context, id uuid.UUID) *contract.Response {
resp, err := s.notificationProcessor.GetByID(ctx, id)
if err != nil {
errResp := contract.NewResponseError(constants.NotFoundErrorCode, constants.NotificationServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errResp})
}
return contract.BuildSuccessResponse(transformer.NotificationModelResponseToContract(resp))
}
@@ -0,0 +1,171 @@
package service
import (
"context"
"fmt"
"log"
"sync"
"time"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
"github.com/google/uuid"
)
const (
defaultCheckInterval = 1 * time.Hour
OmsetMillionRupiah = 1_000_000.0
)
// OmsetMilestoneScheduler periodically checks each organization's total omset
// and sends a notification to owner/admin users when a milestone is reached.
//
// NOTE: Milestone tracking is in-memory; notifications may re-trigger after a restart.
// For persistent tracking, persist the notified state in the database.
type OmsetMilestoneScheduler struct {
orgRepo *repository.OrganizationRepositoryImpl
userRepo *repository.UserRepositoryImpl
notificationProc processor.NotificationProcessor
mu sync.Mutex
notified map[string]bool // "orgID:milestone" -> already notified
stopCh chan struct{}
}
func NewOmsetMilestoneScheduler(
orgRepo *repository.OrganizationRepositoryImpl,
userRepo *repository.UserRepositoryImpl,
notificationProc processor.NotificationProcessor,
) *OmsetMilestoneScheduler {
return &OmsetMilestoneScheduler{
orgRepo: orgRepo,
userRepo: userRepo,
notificationProc: notificationProc,
notified: make(map[string]bool),
stopCh: make(chan struct{}),
}
}
// Start begins the periodic milestone check in a background goroutine.
func (s *OmsetMilestoneScheduler) Start(interval time.Duration) {
if interval <= 0 {
interval = defaultCheckInterval
}
go func() {
// Perform an initial check immediately.
s.checkAllOrganizations()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.checkAllOrganizations()
case <-s.stopCh:
log.Println("Omset milestone scheduler stopped")
return
}
}
}()
log.Println("Omset milestone scheduler started")
}
// Stop signals the scheduler to stop.
func (s *OmsetMilestoneScheduler) Stop() {
close(s.stopCh)
}
func (s *OmsetMilestoneScheduler) checkAllOrganizations() {
ctx := context.Background()
orgs, _, err := s.orgRepo.List(ctx, nil, 1000, 0)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to list organizations: %v", err)
return
}
for _, org := range orgs {
s.checkOrganization(ctx, org)
}
}
func (s *OmsetMilestoneScheduler) checkOrganization(ctx context.Context, org *entities.Organization) {
totalOmset, err := s.orgRepo.GetTotalOmset(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get total omset for org %s: %v", org.ID, err)
return
}
milestones := []float64{OmsetMillionRupiah}
for _, milestone := range milestones {
if totalOmset < milestone {
continue
}
key := fmt.Sprintf("%s:%.0f", org.ID.String(), milestone)
s.mu.Lock()
if s.notified[key] {
s.mu.Unlock()
continue
}
s.notified[key] = true
s.mu.Unlock()
s.sendMilestoneNotification(ctx, org, totalOmset, milestone)
}
}
func (s *OmsetMilestoneScheduler) sendMilestoneNotification(ctx context.Context, org *entities.Organization, totalOmset float64, milestone float64) {
users, err := s.userRepo.GetByOrganizationID(ctx, org.ID)
if err != nil {
log.Printf("OmsetMilestoneScheduler: failed to get users for org %s: %v", org.ID, err)
return
}
// Notify owner and admin users.
var receiverIDs []uuid.UUID
for _, user := range users {
roleStr := string(user.Role)
if roleStr == string(constants.RoleOwner) || roleStr == string(constants.RoleAdmin) {
receiverIDs = append(receiverIDs, user.ID)
}
}
if len(receiverIDs) == 0 {
return
}
orgID := org.ID
title := "🎉 Selamat! Omset Telah Mencapai 1 Juta Rupiah"
body := fmt.Sprintf("Organisasi %s telah mencapai omset Rp %.0f. Terus tingkatkan prestasinya!", org.Name, totalOmset)
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "milestone",
Category: "omset_milestone",
NotifiableType: "organization",
NotifiableID: &orgID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"organization_id": org.ID.String(),
"total_omset": totalOmset,
"milestone": milestone,
},
}
if _, err := s.notificationProc.Send(ctx, notifReq); err != nil {
log.Printf("OmsetMilestoneScheduler: failed to send notification for org %s: %v", org.ID, err)
} else {
log.Printf("OmsetMilestoneScheduler: sent milestone notification to org %s (omset: %.0f)", org.ID, totalOmset)
}
}
+83 -3
View File
@@ -16,6 +16,11 @@ import (
"github.com/google/uuid"
)
// orderUserRepository is a minimal interface to fetch users by organization for notification purposes.
type orderUserRepository interface {
GetActiveByOutletID(ctx context.Context, organizationID, outletID uuid.UUID) ([]*entities.User, error)
}
type OrderService interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
@@ -37,9 +42,12 @@ type OrderServiceImpl struct {
orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor
productRecipeRepo repository.ProductRecipeRepository
txManager *repository.TxManager
sessionRepo repository.SessionRepository
notificationProcessor processor.NotificationProcessor
userRepo orderUserRepository
}
func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface, orderIngredientTransactionService *OrderIngredientTransactionService, orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor, productRecipeRepo repository.ProductRecipeRepository, txManager *repository.TxManager) *OrderServiceImpl {
func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repository.TableRepositoryInterface, orderIngredientTransactionService *OrderIngredientTransactionService, orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor, productRecipeRepo repository.ProductRecipeRepository, txManager *repository.TxManager, sessionRepo repository.SessionRepository, notificationProcessor processor.NotificationProcessor, userRepo orderUserRepository) *OrderServiceImpl {
return &OrderServiceImpl{
orderProcessor: orderProcessor,
tableRepo: tableRepo,
@@ -47,6 +55,9 @@ func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repo
orderIngredientTransactionProcessor: orderIngredientTransactionProcessor,
productRecipeRepo: productRecipeRepo,
txManager: txManager,
sessionRepo: sessionRepo,
notificationProcessor: notificationProcessor,
userRepo: userRepo,
}
}
@@ -102,10 +113,73 @@ func (s *OrderServiceImpl) CreateOrder(ctx context.Context, req *models.CreateOr
return nil, err
}
// Send notification to all org users if this is a self-order
if isSelfOrder(req.Metadata) {
go s.sendSelfOrderNotification(context.Background(), response, organizationID)
}
return response, nil
}
// createIngredientTransactions creates ingredient transactions for order items efficiently
// isSelfOrder checks if the order metadata indicates a self-order.
func isSelfOrder(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
v, ok := metadata["self_order"]
if !ok {
return false
}
b, ok := v.(bool)
return ok && b
}
// sendSelfOrderNotification sends a new-order notification to all active users
// that can access the outlet where the self-order was placed.
func (s *OrderServiceImpl) sendSelfOrderNotification(ctx context.Context, order *models.OrderResponse, organizationID uuid.UUID) {
if s.notificationProcessor == nil || s.userRepo == nil {
return
}
users, err := s.userRepo.GetActiveByOutletID(ctx, organizationID, order.OutletID)
if err != nil || len(users) == 0 {
return
}
receiverIDs := make([]uuid.UUID, 0, len(users))
for _, u := range users {
receiverIDs = append(receiverIDs, u.ID)
}
tableName := ""
if order.TableNumber != nil {
tableName = *order.TableNumber
}
title := "Pesanan Baru Masuk"
body := fmt.Sprintf("Ada pesanan baru dari meja %s", tableName)
if tableName == "" {
body = "Ada pesanan baru masuk"
}
orderID := order.ID
notifReq := &models.SendNotificationRequest{
Title: title,
Body: body,
Type: "order",
Category: "self_order",
NotifiableType: "order",
NotifiableID: &orderID,
ReceiverIDs: receiverIDs,
Data: map[string]interface{}{
"order_id": order.ID.String(),
"order_number": order.OrderNumber,
"table_name": tableName,
},
}
_, _ = s.notificationProcessor.Send(ctx, notifReq)
}
func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, orderID uuid.UUID, orderItems []models.OrderItemResponse) ([]*contract.CreateOrderIngredientTransactionRequest, error) {
appCtx := appcontext.FromGinContext(ctx)
organizationID := appCtx.OrganizationID
@@ -125,7 +199,7 @@ func (s *OrderServiceImpl) createIngredientTransactions(ctx context.Context, ord
// Calculate waste quantities
transactions, err := s.calculateWasteQuantities(productRecipes, float64(orderItem.Quantity))
if err != nil {
return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", err)
return nil, fmt.Errorf("failed to calculate waste quantities for product %s: %w", orderItem.ProductID, err)
}
// Set common fields for all transactions
@@ -621,6 +695,12 @@ func (s *OrderServiceImpl) handleTableReleaseOnPayment(ctx context.Context, orde
if err := s.tableRepo.ReleaseTable(ctx, table.ID, order.TotalAmount); err != nil {
return fmt.Errorf("failed to release table: %w", err)
}
if s.sessionRepo != nil {
if err := s.sessionRepo.CloseByTableID(ctx, table.ID); err != nil {
fmt.Printf("Warning: failed to close self-order session for table %s: %v\n", table.ID, err)
}
}
}
}
@@ -114,6 +114,14 @@ func (m *MockTableRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
return args.Get(0).(*entities.Table), args.Error(1)
}
func (m *MockTableRepository) GetByToken(ctx context.Context, token string) (*entities.Table, error) {
args := m.Called(ctx, token)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*entities.Table), args.Error(1)
}
func (m *MockTableRepository) GetByOutletID(ctx context.Context, outletID uuid.UUID) ([]entities.Table, error) {
args := m.Called(ctx, outletID)
if args.Get(0) == nil {
@@ -182,6 +190,11 @@ func (m *MockTableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUI
return args.Get(0).(*entities.Table), args.Error(1)
}
func (m *MockTableRepository) UpdateToken(ctx context.Context, tableID uuid.UUID, token string) error {
args := m.Called(ctx, tableID, token)
return args.Error(0)
}
func TestCreateOrderWithTableOccupation(t *testing.T) {
// Setup
ctx := context.Background()
@@ -0,0 +1,125 @@
package service
import (
"context"
"errors"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ProductOutletPriceService interface {
Upsert(ctx context.Context, req *contract.CreateProductOutletPriceRequest) *contract.Response
GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) *contract.Response
GetByProduct(ctx context.Context, productID uuid.UUID) *contract.Response
GetByOutlet(ctx context.Context, outletID uuid.UUID) *contract.Response
Delete(ctx context.Context, id uuid.UUID) *contract.Response
BulkUpsert(ctx context.Context, req *contract.BulkCreateProductOutletPriceRequest) *contract.Response
}
type ProductOutletPriceServiceImpl struct {
processor processor.ProductOutletPriceProcessor
}
func NewProductOutletPriceService(proc processor.ProductOutletPriceProcessor) *ProductOutletPriceServiceImpl {
return &ProductOutletPriceServiceImpl{
processor: proc,
}
}
func (s *ProductOutletPriceServiceImpl) Upsert(ctx context.Context, req *contract.CreateProductOutletPriceRequest) *contract.Response {
modelReq := transformer.CreateProductOutletPriceRequestToModel(req)
result, err := s.processor.Upsert(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResp := transformer.ProductOutletPriceModelToResponse(result)
return contract.BuildSuccessResponse(contractResp)
}
func (s *ProductOutletPriceServiceImpl) GetByProductAndOutlet(ctx context.Context, productID, outletID uuid.UUID) *contract.Response {
result, err := s.processor.GetByProductAndOutlet(ctx, productID, outletID)
if err != nil {
code := constants.InternalServerErrorCode
if errors.Is(err, gorm.ErrRecordNotFound) {
code = constants.NotFoundErrorCode
}
errorResp := contract.NewResponseError(code, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResp := transformer.ProductOutletPriceModelToResponse(result)
return contract.BuildSuccessResponse(contractResp)
}
func (s *ProductOutletPriceServiceImpl) GetByProduct(ctx context.Context, productID uuid.UUID) *contract.Response {
results, err := s.processor.GetByProduct(ctx, productID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResps := transformer.ProductOutletPriceModelsToResponses(results)
return contract.BuildSuccessResponse(&contract.ListProductOutletPricesResponse{
Prices: contractResps,
TotalCount: len(contractResps),
})
}
func (s *ProductOutletPriceServiceImpl) GetByOutlet(ctx context.Context, outletID uuid.UUID) *contract.Response {
results, err := s.processor.GetByOutlet(ctx, outletID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResps := transformer.ProductOutletPriceModelsToResponses(results)
return contract.BuildSuccessResponse(&contract.ListProductOutletPricesResponse{
Prices: contractResps,
TotalCount: len(contractResps),
})
}
func (s *ProductOutletPriceServiceImpl) Delete(ctx context.Context, id uuid.UUID) *contract.Response {
err := s.processor.Delete(ctx, id)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{
"message": "Product outlet price deleted successfully",
})
}
func (s *ProductOutletPriceServiceImpl) BulkUpsert(ctx context.Context, req *contract.BulkCreateProductOutletPriceRequest) *contract.Response {
prices := make([]models.CreateProductOutletPriceRequest, len(req.Prices))
for i, p := range req.Prices {
prices[i] = models.CreateProductOutletPriceRequest{
ProductID: req.ProductID,
OutletID: p.OutletID,
Price: p.Price,
}
}
results, err := s.processor.BulkUpsert(ctx, req.ProductID, prices)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductOutletPriceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResps := transformer.ProductOutletPriceModelsToResponses(results)
return contract.BuildSuccessResponse(&contract.ListProductOutletPricesResponse{
Prices: contractResps,
TotalCount: len(contractResps),
})
}
+64 -6
View File
@@ -14,10 +14,11 @@ import (
type ProductService interface {
CreateProduct(ctx context.Context, apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *contract.Response
UpdateProduct(ctx context.Context, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response
UpdateProduct(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response
DeleteProduct(ctx context.Context, id uuid.UUID) *contract.Response
GetProductByID(ctx context.Context, id uuid.UUID) *contract.Response
GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response
ListProducts(ctx context.Context, req *contract.ListProductsRequest) *contract.Response
ListProductsAll(ctx context.Context, req *contract.ListProductsRequest) *contract.Response
}
type ProductServiceImpl struct {
@@ -43,8 +44,8 @@ func (s *ProductServiceImpl) CreateProduct(ctx context.Context, apctx *appcontex
return contract.BuildSuccessResponse(contractResponse)
}
func (s *ProductServiceImpl) UpdateProduct(ctx context.Context, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response {
modelReq := transformer.UpdateProductRequestToModel(req)
func (s *ProductServiceImpl) UpdateProduct(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, req *contract.UpdateProductRequest) *contract.Response {
modelReq := transformer.UpdateProductRequestToModel(apctx, req)
productResponse, err := s.productProcessor.UpdateProduct(ctx, id, modelReq)
if err != nil {
@@ -68,8 +69,8 @@ func (s *ProductServiceImpl) DeleteProduct(ctx context.Context, id uuid.UUID) *c
})
}
func (s *ProductServiceImpl) GetProductByID(ctx context.Context, id uuid.UUID) *contract.Response {
productResponse, err := s.productProcessor.GetProductByID(ctx, id)
func (s *ProductServiceImpl) GetProductByID(ctx context.Context, id uuid.UUID, outletID uuid.UUID) *contract.Response {
productResponse, err := s.productProcessor.GetProductByID(ctx, id, outletID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
@@ -85,6 +86,63 @@ func (s *ProductServiceImpl) ListProducts(ctx context.Context, req *contract.Lis
if req.OrganizationID != nil {
filters["organization_id"] = *req.OrganizationID
}
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.CategoryID != nil {
filters["category_id"] = *req.CategoryID
}
if req.BusinessType != "" {
filters["business_type"] = req.BusinessType
}
if req.IsActive != nil {
filters["is_active"] = *req.IsActive
}
if req.Search != "" {
filters["search"] = req.Search
}
if req.MinPrice != nil {
filters["price_min"] = *req.MinPrice
}
if req.MaxPrice != nil {
filters["price_max"] = *req.MaxPrice
}
products, totalCount, err := s.productProcessor.ListProducts(ctx, filters, req.Page, req.Limit)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.ProductServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
// Convert to contract responses
contractResponses := transformer.ProductsToResponses(products)
// Calculate total pages
totalPages := totalCount / req.Limit
if totalCount%req.Limit > 0 {
totalPages++
}
listResponse := &contract.ListProductsResponse{
Products: contractResponses,
TotalCount: totalCount,
Page: req.Page,
Limit: req.Limit,
TotalPages: totalPages,
}
return contract.BuildSuccessResponse(listResponse)
}
func (s *ProductServiceImpl) ListProductsAll(ctx context.Context, req *contract.ListProductsRequest) *contract.Response {
// Build filters
filters := make(map[string]interface{})
if req.OrganizationID != nil {
filters["organization_id"] = *req.OrganizationID
}
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.CategoryID != nil {
filters["category_id"] = *req.CategoryID
}
+4
View File
@@ -152,3 +152,7 @@ func (s *TableServiceImpl) GetOccupiedTables(ctx context.Context, outletID uuid.
return contract.BuildSuccessResponse(responses)
}
func (s *TableServiceImpl) GetTableToken(ctx context.Context, tableID uuid.UUID) (string, error) {
return s.tableProcessor.GetTokenByID(ctx, tableID)
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"context"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
"github.com/google/uuid"
)
type UserDeviceService interface {
RegisterDevice(ctx context.Context, userID uuid.UUID, req *contract.RegisterUserDeviceRequest) *contract.Response
UpdateDevice(ctx context.Context, id uuid.UUID, req *contract.UpdateUserDeviceRequest) *contract.Response
DeleteDevice(ctx context.Context, id uuid.UUID) *contract.Response
GetDeviceByID(ctx context.Context, id uuid.UUID) *contract.Response
GetDevicesByUserID(ctx context.Context, userID uuid.UUID) *contract.Response
ListDevices(ctx context.Context, req *contract.ListUserDevicesRequest) *contract.Response
}
type UserDeviceServiceImpl struct {
userDeviceProcessor processor.UserDeviceProcessor
}
func NewUserDeviceService(userDeviceProcessor processor.UserDeviceProcessor) *UserDeviceServiceImpl {
return &UserDeviceServiceImpl{
userDeviceProcessor: userDeviceProcessor,
}
}
func (s *UserDeviceServiceImpl) RegisterDevice(ctx context.Context, userID uuid.UUID, req *contract.RegisterUserDeviceRequest) *contract.Response {
modelReq := transformer.RegisterUserDeviceRequestToModel(req)
modelReq.UserID = userID
deviceResponse, err := s.userDeviceProcessor.RegisterDevice(ctx, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponse := transformer.UserDeviceModelResponseToResponse(deviceResponse)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *UserDeviceServiceImpl) UpdateDevice(ctx context.Context, id uuid.UUID, req *contract.UpdateUserDeviceRequest) *contract.Response {
modelReq := transformer.UpdateUserDeviceRequestToModel(req)
deviceResponse, err := s.userDeviceProcessor.UpdateDevice(ctx, id, modelReq)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponse := transformer.UserDeviceModelResponseToResponse(deviceResponse)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *UserDeviceServiceImpl) DeleteDevice(ctx context.Context, id uuid.UUID) *contract.Response {
err := s.userDeviceProcessor.DeleteDevice(ctx, id)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
return contract.BuildSuccessResponse(map[string]interface{}{
"message": "Device deleted successfully",
})
}
func (s *UserDeviceServiceImpl) GetDeviceByID(ctx context.Context, id uuid.UUID) *contract.Response {
deviceResponse, err := s.userDeviceProcessor.GetDeviceByID(ctx, id)
if err != nil {
errorResp := contract.NewResponseError(constants.NotFoundErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponse := transformer.UserDeviceModelResponseToResponse(deviceResponse)
return contract.BuildSuccessResponse(contractResponse)
}
func (s *UserDeviceServiceImpl) GetDevicesByUserID(ctx context.Context, userID uuid.UUID) *contract.Response {
deviceResponses, err := s.userDeviceProcessor.GetDevicesByUserID(ctx, userID)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponses := transformer.UserDeviceModelResponsesToResponses(deviceResponses)
return contract.BuildSuccessResponse(contractResponses)
}
func (s *UserDeviceServiceImpl) ListDevices(ctx context.Context, req *contract.ListUserDevicesRequest) *contract.Response {
modelReq := transformer.ListUserDevicesRequestToModel(req)
filters := make(map[string]interface{})
if modelReq.UserID != "" {
filters["user_id"] = modelReq.UserID
}
if modelReq.Platform != "" {
filters["platform"] = modelReq.Platform
}
devices, totalPages, err := s.userDeviceProcessor.ListDevices(ctx, filters, modelReq.Page, modelReq.Limit)
if err != nil {
errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.UserDeviceServiceEntity, err.Error())
return contract.BuildErrorResponse([]*contract.ResponseError{errorResp})
}
contractResponses := transformer.UserDeviceModelResponsesToResponses(devices)
response := contract.ListUserDevicesResponse{
Devices: contractResponses,
TotalCount: len(contractResponses),
Page: modelReq.Page,
Limit: modelReq.Limit,
TotalPages: totalPages,
}
return contract.BuildSuccessResponse(response)
}
+105 -6
View File
@@ -6,8 +6,22 @@ import (
"apskel-pos-be/internal/util"
"fmt"
"time"
"github.com/google/uuid"
)
// parseOutletID converts a *string outlet ID to *uuid.UUID, returning nil for invalid/empty values.
func parseOutletID(s *string) *uuid.UUID {
if s == nil {
return nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil
}
return &id
}
// PaymentMethodAnalyticsContractToModel converts contract request to model
func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsRequest) *models.PaymentMethodAnalyticsRequest {
var dateFrom, dateTo time.Time
@@ -23,7 +37,7 @@ func PaymentMethodAnalyticsContractToModel(req *contract.PaymentMethodAnalyticsR
return &models.PaymentMethodAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
GroupBy: req.GroupBy,
@@ -79,7 +93,7 @@ func SalesAnalyticsContractToModel(req *contract.SalesAnalyticsRequest) *models.
return &models.SalesAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
GroupBy: req.GroupBy,
@@ -124,6 +138,91 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
}
}
// PurchasingAnalyticsContractToModel converts contract request to model
func PurchasingAnalyticsContractToModel(req *contract.PurchasingAnalyticsRequest) *models.PurchasingAnalyticsRequest {
var dateFrom, dateTo time.Time
if fromTime, toTime, err := util.ParseDateRangeToJakartaTime(req.DateFrom, req.DateTo); err == nil {
if fromTime != nil {
dateFrom = *fromTime
}
if toTime != nil {
dateTo = *toTime
}
}
return &models.PurchasingAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
GroupBy: req.GroupBy,
}
}
// PurchasingAnalyticsModelToContract converts model response to contract
func PurchasingAnalyticsModelToContract(resp *models.PurchasingAnalyticsResponse) *contract.PurchasingAnalyticsResponse {
if resp == nil {
return nil
}
data := make([]contract.PurchasingAnalyticsData, len(resp.Data))
for i, item := range resp.Data {
data[i] = contract.PurchasingAnalyticsData{
Date: item.Date,
Purchases: item.Purchases,
PurchaseOrders: item.PurchaseOrders,
Quantity: item.Quantity,
Ingredients: item.Ingredients,
Vendors: item.Vendors,
}
}
ingredientData := make([]contract.PurchasingIngredientData, len(resp.IngredientData))
for i, item := range resp.IngredientData {
ingredientData[i] = contract.PurchasingIngredientData{
IngredientID: item.IngredientID,
IngredientName: item.IngredientName,
Quantity: item.Quantity,
TotalCost: item.TotalCost,
AverageUnitCost: item.AverageUnitCost,
PurchaseOrderCount: item.PurchaseOrderCount,
}
}
vendorData := make([]contract.PurchasingVendorData, len(resp.VendorData))
for i, item := range resp.VendorData {
vendorData[i] = contract.PurchasingVendorData{
VendorID: item.VendorID,
VendorName: item.VendorName,
TotalCost: item.TotalCost,
PurchaseOrderCount: item.PurchaseOrderCount,
IngredientCount: item.IngredientCount,
Quantity: item.Quantity,
}
}
return &contract.PurchasingAnalyticsResponse{
OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom,
DateTo: resp.DateTo,
GroupBy: resp.GroupBy,
Summary: contract.PurchasingSummary{
TotalPurchases: resp.Summary.TotalPurchases,
TotalPurchaseOrders: resp.Summary.TotalPurchaseOrders,
TotalQuantity: resp.Summary.TotalQuantity,
AveragePurchaseOrderValue: resp.Summary.AveragePurchaseOrderValue,
TotalIngredients: resp.Summary.TotalIngredients,
TotalVendors: resp.Summary.TotalVendors,
},
Data: data,
IngredientData: ingredientData,
VendorData: vendorData,
}
}
// ProductAnalyticsContractToModel converts contract request to model
func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *models.ProductAnalyticsRequest {
var dateFrom, dateTo time.Time
@@ -139,7 +238,7 @@ func ProductAnalyticsContractToModel(req *contract.ProductAnalyticsRequest) *mod
return &models.ProductAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
Limit: req.Limit,
@@ -199,7 +298,7 @@ func ProductAnalyticsPerCategoryContractToModel(req *contract.ProductAnalyticsPe
return &models.ProductAnalyticsPerCategoryRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
}
@@ -251,7 +350,7 @@ func DashboardAnalyticsContractToModel(req *contract.DashboardAnalyticsRequest)
return &models.DashboardAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: dateFrom,
DateTo: dateTo,
}
@@ -346,7 +445,7 @@ func ProfitLossAnalyticsContractToModel(req *contract.ProfitLossAnalyticsRequest
return &models.ProfitLossAnalyticsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
OutletID: parseOutletID(req.OutletID),
DateFrom: *dateFrom,
DateTo: *dateTo,
GroupBy: req.GroupBy,
@@ -0,0 +1,76 @@
package transformer
import (
"encoding/json"
"testing"
"time"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
func TestPurchasingAnalyticsContractToModelParsesDateRangeAndOutlet(t *testing.T) {
orgID := uuid.New()
outletID := uuid.New().String()
req := &contract.PurchasingAnalyticsRequest{
OrganizationID: orgID,
OutletID: &outletID,
DateFrom: "01-05-2026",
DateTo: "02-05-2026",
GroupBy: "week",
}
result := PurchasingAnalyticsContractToModel(req)
require.Equal(t, orgID, result.OrganizationID)
require.NotNil(t, result.OutletID)
require.Equal(t, outletID, result.OutletID.String())
require.Equal(t, "week", result.GroupBy)
location, err := time.LoadLocation("Asia/Jakarta")
require.NoError(t, err)
require.Equal(t, time.Date(2026, 5, 1, 0, 0, 0, 0, location), result.DateFrom)
require.Equal(t, time.Date(2026, 5, 2, 23, 59, 59, int(time.Second-time.Nanosecond), location), result.DateTo)
}
func TestPurchasingAnalyticsContractToModelIgnoresInvalidOutlet(t *testing.T) {
outletID := "not-a-uuid"
result := PurchasingAnalyticsContractToModel(&contract.PurchasingAnalyticsRequest{
OutletID: &outletID,
DateFrom: "01-05-2026",
DateTo: "02-05-2026",
})
require.Nil(t, result.OutletID)
}
func TestPurchasingAnalyticsModelToContractCopiesOutletName(t *testing.T) {
outletID := uuid.New()
outletName := "Main Outlet"
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
OutletID: &outletID,
OutletName: &outletName,
})
require.NotNil(t, result)
require.Equal(t, &outletID, result.OutletID)
require.NotNil(t, result.OutletName)
require.Equal(t, outletName, *result.OutletName)
}
func TestPurchasingAnalyticsModelToContractOmitsNilOutletName(t *testing.T) {
result := PurchasingAnalyticsModelToContract(&models.PurchasingAnalyticsResponse{
OrganizationID: uuid.New(),
})
payload, err := json.Marshal(result)
require.NoError(t, err)
require.NotContains(t, string(payload), "outlet_name")
}
+9 -2
View File
@@ -7,12 +7,17 @@ import (
)
func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest {
order := 0
if req.Order != nil {
order = *req.Order
}
return &models.CreateCategoryRequest{
OrganizationID: apctx.OrganizationID,
OutletID: req.OutletID,
Name: req.Name,
Description: req.Description,
ImageURL: nil,
Order: *req.Order,
Order: order,
}
}
@@ -21,6 +26,7 @@ func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.U
Name: req.Name,
Description: req.Description,
ImageURL: nil,
OutletID: req.OutletID,
Order: req.Order,
IsActive: nil,
}
@@ -34,9 +40,10 @@ func CategoryModelResponseToResponse(cat *models.CategoryResponse) *contract.Cat
return &contract.CategoryResponse{
ID: cat.ID,
OrganizationID: cat.OrganizationID,
OutletID: cat.OutletID,
Name: cat.Name,
Description: cat.Description,
BusinessType: "restaurant", // Default business type
BusinessType: "restaurant",
Order: cat.Order,
Metadata: map[string]interface{}{},
CreatedAt: cat.CreatedAt,
@@ -0,0 +1,63 @@
package transformer
import (
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
)
func NotificationModelResponseToContract(m *models.NotificationResponse) *contract.NotificationResponse {
if m == nil {
return nil
}
return &contract.NotificationResponse{
ID: m.ID,
Title: m.Title,
Body: m.Body,
Type: m.Type,
Category: m.Category,
Priority: m.Priority,
ImageURL: m.ImageURL,
ActionURL: m.ActionURL,
NotifiableType: m.NotifiableType,
NotifiableID: m.NotifiableID,
Data: m.Data,
ScheduledAt: m.ScheduledAt,
SentAt: m.SentAt,
ExpiredAt: m.ExpiredAt,
CreatedBy: m.CreatedBy,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
func NotificationReceiverModelResponseToContract(m *models.NotificationReceiverResponse) *contract.NotificationReceiverResponse {
if m == nil {
return nil
}
resp := &contract.NotificationReceiverResponse{
ID: m.ID,
NotificationID: m.NotificationID,
UserID: m.UserID,
IsRead: m.IsRead,
ReadAt: m.ReadAt,
IsDeleted: m.IsDeleted,
DeletedAt: m.DeletedAt,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
if m.Notification != nil {
resp.Notification = NotificationModelResponseToContract(m.Notification)
}
return resp
}
func NotificationReceiverModelResponsesToContracts(ms []*models.NotificationReceiverResponse) []*contract.NotificationReceiverResponse {
if ms == nil {
return nil
}
result := make([]*contract.NotificationReceiverResponse, len(ms))
for i, m := range ms {
result[i] = NotificationReceiverModelResponseToContract(m)
}
return result
}
@@ -0,0 +1,55 @@
package transformer
import (
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
)
func CreateProductOutletPriceRequestToModel(req *contract.CreateProductOutletPriceRequest) *models.CreateProductOutletPriceRequest {
if req == nil {
return nil
}
return &models.CreateProductOutletPriceRequest{
ProductID: req.ProductID,
OutletID: req.OutletID,
Price: req.Price,
}
}
func UpdateProductOutletPriceRequestToModel(req *contract.UpdateProductOutletPriceRequest) *models.UpdateProductOutletPriceRequest {
if req == nil {
return nil
}
return &models.UpdateProductOutletPriceRequest{
Price: &req.Price,
}
}
func ProductOutletPriceModelToResponse(m *models.ProductOutletPrice) *contract.ProductOutletPriceResponse {
if m == nil {
return nil
}
return &contract.ProductOutletPriceResponse{
ID: m.ID,
ProductID: m.ProductID,
OutletID: m.OutletID,
Price: m.Price,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}
func ProductOutletPriceModelsToResponses(ms []*models.ProductOutletPrice) []contract.ProductOutletPriceResponse {
if ms == nil {
return nil
}
responses := make([]contract.ProductOutletPriceResponse, len(ms))
for i, m := range ms {
responses[i] = *ProductOutletPriceModelToResponse(m)
}
return responses
}
+32 -1
View File
@@ -5,6 +5,8 @@ import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"github.com/google/uuid"
)
func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateProductRequest) *models.CreateProductRequest {
@@ -37,8 +39,15 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
metadata = make(map[string]interface{})
}
// Prioritize outlet_id from context, fallback to request body
outletID := apctx.OutletID
if outletID == uuid.Nil && req.OutletID != nil {
outletID = *req.OutletID
}
return &models.CreateProductRequest{
OrganizationID: apctx.OrganizationID,
OutletID: outletID,
CategoryID: req.CategoryID,
SKU: req.SKU,
Name: req.Name,
@@ -53,13 +62,20 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
}
}
func UpdateProductRequestToModel(req *contract.UpdateProductRequest) *models.UpdateProductRequest {
func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.UpdateProductRequest) *models.UpdateProductRequest {
metadata := req.Metadata
if metadata == nil {
metadata = make(map[string]interface{})
}
// Prioritize outlet_id from context, fallback to request body
outletID := apctx.OutletID
if outletID == uuid.Nil && req.OutletID != nil {
outletID = *req.OutletID
}
return &models.UpdateProductRequest{
OutletID: outletID,
CategoryID: req.CategoryID,
SKU: req.SKU,
Name: req.Name,
@@ -97,6 +113,19 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
}
}
// Convert outlet prices
var outletPriceResponses []contract.ProductOutletPriceResponse
if len(prod.OutletPrices) > 0 {
outletPriceResponses = make([]contract.ProductOutletPriceResponse, len(prod.OutletPrices))
for i, op := range prod.OutletPrices {
outletPriceResponses[i] = contract.ProductOutletPriceResponse{
OutletID: op.OutletID,
OutletName: op.OutletName,
Price: op.Price,
}
}
}
return &contract.ProductResponse{
ID: prod.ID,
OrganizationID: prod.OrganizationID,
@@ -106,6 +135,8 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
Name: prod.Name,
Description: prod.Description,
Price: prod.Price,
OutletPrice: prod.OutletPrice,
OutletPrices: outletPriceResponses,
Cost: prod.Cost,
BusinessType: string(prod.BusinessType),
ImageURL: prod.ImageURL,
@@ -0,0 +1,74 @@
package transformer
import (
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
)
func RegisterUserDeviceRequestToModel(req *contract.RegisterUserDeviceRequest) *models.RegisterUserDeviceRequest {
return &models.RegisterUserDeviceRequest{
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
Platform: req.Platform,
FCMToken: req.FCMToken,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
}
}
func UpdateUserDeviceRequestToModel(req *contract.UpdateUserDeviceRequest) *models.UpdateUserDeviceRequest {
return &models.UpdateUserDeviceRequest{
DeviceName: req.DeviceName,
DeviceType: req.DeviceType,
Platform: req.Platform,
FCMToken: req.FCMToken,
AppVersion: req.AppVersion,
OsVersion: req.OsVersion,
}
}
func ListUserDevicesRequestToModel(req *contract.ListUserDevicesRequest) *models.ListUserDevicesRequest {
return &models.ListUserDevicesRequest{
Page: req.Page,
Limit: req.Limit,
UserID: req.UserID,
Platform: req.Platform,
}
}
func UserDeviceModelResponseToResponse(device *models.UserDeviceResponse) *contract.UserDeviceResponse {
if device == nil {
return nil
}
return &contract.UserDeviceResponse{
ID: device.ID,
UserID: device.UserID,
DeviceID: device.DeviceID,
DeviceName: device.DeviceName,
DeviceType: device.DeviceType,
Platform: device.Platform,
FCMToken: device.FCMToken,
AppVersion: device.AppVersion,
OsVersion: device.OsVersion,
IPAddress: device.IPAddress,
LastActiveAt: device.LastActiveAt,
CreatedAt: device.CreatedAt,
UpdatedAt: device.UpdatedAt,
}
}
func UserDeviceModelResponsesToResponses(devices []*models.UserDeviceResponse) []contract.UserDeviceResponse {
if devices == nil {
return nil
}
responses := make([]contract.UserDeviceResponse, len(devices))
for i, device := range devices {
response := UserDeviceModelResponseToResponse(device)
if response != nil {
responses[i] = *response
}
}
return responses
}

Some files were not shown because too many files have changed in this diff Show More