Compare commits

..
Author SHA1 Message Date
ryan dc5a823508 add migration for outlet 2026-05-13 15:22:23 +07:00
ryan 691e2ea614 add outlet association to category entity and related layers 2026-05-13 15:05:03 +07:00
ryan c5f94229a7 add OutletID field to category across all layers 2026-05-13 15:04:42 +07: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
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
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
57 changed files with 1698 additions and 123 deletions
+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)
+1
View File
@@ -26,6 +26,7 @@ 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"`
+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
}
+4 -3
View File
@@ -1,7 +1,8 @@
package config
type Server struct {
Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"`
Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"`
SelfOrderUrl string `mapstructure:"self-order-url"`
}
+5 -3
View File
@@ -1,6 +1,6 @@
module apskel-pos-be
go 1.23.0
go 1.24
require (
github.com/gin-gonic/gin v1.9.1
@@ -57,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
@@ -86,7 +86,7 @@ require (
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.10.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.42.0 // indirect
@@ -108,7 +108,9 @@ require (
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.10.0
go.uber.org/zap v1.21.0
+13 -4
View File
@@ -78,6 +78,12 @@ 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=
@@ -255,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=
@@ -294,6 +300,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1
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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
@@ -370,8 +378,9 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstF
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=
+13 -1
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
@@ -46,4 +58,4 @@ fonnte:
fcm:
credentials_file: "infra/firebase-service-account.json"
project_id: "your-firebase-project-id"
project_id: "apskel-pos-v2"
+42 -9
View File
@@ -20,30 +20,52 @@ 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
router *router.Router
shutdown chan os.Signal
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,
shutdown: make(chan os.Signal, 1),
db: db,
redisClient: redisClient,
shutdown: make(chan os.Signal, 1),
}
}
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,
)
a.router = router.NewRouter(
cfg,
@@ -109,12 +131,18 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.userDeviceValidator,
services.notificationService,
validators.notificationValidator,
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{
@@ -150,6 +178,9 @@ func (a *App) Start(port string) error {
}
func (a *App) Shutdown() {
if a.omsetScheduler != nil {
a.omsetScheduler.Stop()
}
close(a.shutdown)
}
@@ -195,6 +226,7 @@ type repositories struct {
customerAuthRepo repository.CustomerAuthRepository
customerPointsRepo repository.CustomerPointsRepository
otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository
txManager *repository.TxManager
userDeviceRepo *repository.UserDeviceRepositoryImpl
notificationRepo *repository.NotificationRepositoryImpl
@@ -245,6 +277,7 @@ 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),
@@ -393,7 +426,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)
@@ -420,7 +453,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
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),
+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,
}
}
+23 -23
View File
@@ -7,11 +7,11 @@ import (
)
type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"`
OutletID *uuid.UUID `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"`
OrganizationID uuid.UUID `form:"organization_id"`
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"`
}
// PaymentMethodAnalyticsResponse represents the response for payment method analytics
@@ -45,10 +45,10 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `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"`
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 SalesAnalyticsResponse struct {
@@ -86,10 +86,10 @@ type SalesAnalyticsData struct {
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `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"`
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"`
}
// ProductAnalyticsResponse represents the response for product analytics
@@ -123,9 +123,9 @@ 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"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
// ProductAnalyticsPerCategoryResponse represents the response for product analytics per category
@@ -152,9 +152,9 @@ type ProductAnalyticsPerCategoryData struct {
// DashboardAnalyticsRequest represents the request for dashboard analytics
type DashboardAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
OutletID *string `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
// DashboardAnalyticsResponse represents the response for dashboard analytics
@@ -182,10 +182,10 @@ 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"`
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"`
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"`
}
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
+7 -3
View File
@@ -8,22 +8,25 @@ import (
type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
Order *int `json:"order,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
Order *int `json:"order,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,10 +37,11 @@ type ListCategoriesRequest struct {
type CategoryResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description"`
BusinessType string `json:"business_type"`
Order int `json:"order"`
Order int `json:"order"`
Metadata map[string]interface{} `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+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"`
}
+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
}
+11 -9
View File
@@ -31,17 +31,19 @@ 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"`
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"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
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,omitempty"`
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"`
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
}
+4
View File
@@ -37,6 +37,10 @@ func GetAllEntities() []interface{} {
&OtpSession{},
// Analytics entities are not database tables, they are query results
&UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
}
}
+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
}
+18 -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)
@@ -84,7 +96,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 +120,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 +144,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 +168,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")
+6
View File
@@ -170,6 +170,12 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
req.BusinessType = businessType
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID
+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 {
+551
View File
@@ -0,0 +1,551 @@
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
}
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,
) *SelfOrderHandler {
return &SelfOrderHandler{
orderService: orderService,
categoryService: categoryService,
productService: productService,
tableRepo: tableRepo,
outletRepo: outletRepo,
userRepo: userRepo,
sessionRepo: sessionRepo,
orderRepo: orderRepo,
}
}
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(outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
}
func (h *SelfOrderHandler) buildMenuResponse(
outlet *entities.Outlet,
table *entities.Table,
categories []contract.CategoryResponse,
products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse {
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 {
item := contract.SelfOrderMenuItem{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Price: p.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
}
+48 -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,
}
}
@@ -286,3 +291,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)
}
+14 -10
View File
@@ -9,10 +9,11 @@ import (
type Category struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string
Description *string
ImageURL *string
Order int
Order int
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
@@ -20,27 +21,30 @@ type Category struct {
type CreateCategoryRequest struct {
OrganizationID uuid.UUID `validate:"required"`
Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
Order int `validate:"min=0"`
OutletID *uuid.UUID
Name string `validate:"required,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
Order int `validate:"min=0"`
}
type UpdateCategoryRequest struct {
Name *string `validate:"omitempty,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
Order *int `validate:"omitempty,min=0"`
OutletID *uuid.UUID `validate:"omitempty,required"`
Name *string `validate:"omitempty,min=1,max=255"`
Description *string `validate:"omitempty,max=1000"`
ImageURL *string `validate:"omitempty,url"`
Order *int `validate:"omitempty,min=0"`
IsActive *bool
}
type CategoryResponse struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID *uuid.UUID
Name string
Description *string
ImageURL *string
Order int
Order int
IsActive bool
CreatedAt time.Time
UpdatedAt time.Time
+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"`
}
+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
}
+4
View File
@@ -55,6 +55,7 @@ func (p *CategoryProcessorImpl) CreateCategory(ctx context.Context, req *models.
// Map request to entity
categoryEntity := mappers.CreateCategoryRequestToEntity(req)
categoryEntity.OutletID = req.OutletID
// Create category
if err := p.categoryRepo.Create(ctx, categoryEntity); err != nil {
@@ -86,6 +87,9 @@ func (p *CategoryProcessorImpl) UpdateCategory(ctx context.Context, id uuid.UUID
// Apply updates to entity
mappers.UpdateCategoryEntityFromRequest(existingCategory, req)
if req.OutletID != nil {
existingCategory.OutletID = req.OutletID
}
// Update category
if err := p.categoryRepo.Update(ctx, existingCategory); err != nil {
+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,
+14 -21
View File
@@ -29,6 +29,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 +57,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").
@@ -180,9 +185,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 +238,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 +268,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 +319,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 +371,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 +414,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 {
+3 -3
View File
@@ -25,7 +25,7 @@ func (r *CategoryRepositoryImpl) Create(ctx context.Context, category *entities.
func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
var category entities.Category
err := r.db.WithContext(ctx).First(&category, "id = ?", id).Error
err := r.db.WithContext(ctx).Preload("Outlet").First(&category, "id = ?", id).Error
if err != nil {
return nil, err
}
@@ -34,7 +34,7 @@ func (r *CategoryRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*en
func (r *CategoryRepositoryImpl) GetWithProducts(ctx context.Context, id uuid.UUID) (*entities.Category, error) {
var category entities.Category
err := r.db.WithContext(ctx).Preload("Products").First(&category, "id = ?", id).Error
err := r.db.WithContext(ctx).Preload("Products").Preload("Outlet").First(&category, "id = ?", id).Error
if err != nil {
return nil, err
}
@@ -81,7 +81,7 @@ func (r *CategoryRepositoryImpl) List(ctx context.Context, filters map[string]in
return nil, 0, err
}
err := query.Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
err := query.Preload("Outlet").Order("\"order\" ASC").Limit(limit).Offset(offset).Find(&categories).Error
return categories, total, err
}
+24 -14
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
inventory = entities.Inventory{
ProductID: productID,
OutletID: outletID,
Quantity: 0,
ReorderLevel: 0,
}
if err := tx.Create(&inventory).Error; err != nil {
return fmt.Errorf("failed to create inventory record for product %s: %w", productID, err)
}
} else {
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.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)
}
}
inventory.UpdateQuantity(delta)
+19
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
@@ -130,6 +131,24 @@ func (r *OrderRepositoryImpl) List(ctx context.Context, filters map[string]inter
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 = ?", organizationID, "completed").
Select("COALESCE(SUM(total_amount), 0)").
Scan(&total).Error
return 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
}
+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
}
+14 -2
View File
@@ -48,11 +48,12 @@ type Router struct {
spinGameHandler *handler.SpinGameHandler
userDeviceHandler *handler.UserDeviceHandler
notificationHandler *handler.NotificationHandler
selfOrderHandler *handler.SelfOrderHandler
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, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator) *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, selfOrderHandler *handler.SelfOrderHandler) *Router {
return &Router{
config: cfg,
@@ -71,7 +72,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),
@@ -93,6 +94,7 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator),
userDeviceHandler: handler.NewUserDeviceHandler(userDeviceService, userDeviceValidator),
notificationHandler: handler.NewNotificationHandler(notificationService, notificationValidator),
selfOrderHandler: selfOrderHandler,
}
}
@@ -149,6 +151,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)
@@ -316,6 +327,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")
+3
View File
@@ -88,6 +88,9 @@ func (s *CategoryServiceImpl) ListCategories(ctx context.Context, req *contract.
if req.BusinessType != "" {
filters["business_type"] = req.BusinessType
}
if req.OutletID != nil {
filters["outlet_id"] = *req.OutletID
}
if req.Search != "" {
filters["search"] = req.Search
}
@@ -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)
}
}
+82 -2
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
@@ -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)
}
}
}
}
+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)
}
+20 -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,
@@ -139,7 +153,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 +213,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 +265,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 +360,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,
@@ -9,6 +9,7 @@ import (
func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.CreateCategoryRequest) *models.CreateCategoryRequest {
return &models.CreateCategoryRequest{
OrganizationID: apctx.OrganizationID,
OutletID: req.OutletID,
Name: req.Name,
Description: req.Description,
ImageURL: nil,
@@ -18,6 +19,7 @@ func CreateCategoryRequestToModel(apctx *appcontext.ContextInfo, req *contract.C
func UpdateCategoryRequestToModel(req *contract.UpdateCategoryRequest) *models.UpdateCategoryRequest {
return &models.UpdateCategoryRequest{
OutletID: req.OutletID,
Name: req.Name,
Description: req.Description,
ImageURL: nil,
@@ -34,6 +36,7 @@ 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
+1 -1
View File
@@ -59,7 +59,7 @@ func (v *CategoryValidatorImpl) ValidateUpdateCategoryRequest(req *contract.Upda
}
// At least one field should be provided for update
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil {
if req.Name == nil && req.Description == nil && req.BusinessType == nil && req.Metadata == nil && req.OutletID == nil {
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
}
@@ -0,0 +1 @@
ALTER TABLE tables DROP COLUMN IF EXISTS token;
@@ -0,0 +1 @@
ALTER TABLE tables ADD COLUMN token VARCHAR(255) UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notifications;
@@ -0,0 +1,28 @@
-- Notifications table (master notification record)
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
body TEXT,
type VARCHAR(100),
category VARCHAR(100),
priority VARCHAR(50) NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
image_url VARCHAR(512),
action_url VARCHAR(512),
notifiable_type VARCHAR(100),
notifiable_id UUID,
data JSONB,
scheduled_at TIMESTAMP WITH TIME ZONE,
sent_at TIMESTAMP WITH TIME ZONE,
expired_at TIMESTAMP WITH TIME ZONE,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notifications_created_by ON notifications(created_by);
CREATE INDEX idx_notifications_type ON notifications(type);
CREATE INDEX idx_notifications_category ON notifications(category);
CREATE INDEX idx_notifications_notifiable ON notifications(notifiable_type, notifiable_id);
CREATE INDEX idx_notifications_scheduled_at ON notifications(scheduled_at);
CREATE INDEX idx_notifications_sent_at ON notifications(sent_at);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notification_receivers;
@@ -0,0 +1,18 @@
-- Notification receivers table (links a notification to a specific user)
CREATE TABLE notification_receivers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP WITH TIME ZONE,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notification_receivers_notification_id ON notification_receivers(notification_id);
CREATE INDEX idx_notification_receivers_user_id ON notification_receivers(user_id);
CREATE INDEX idx_notification_receivers_user_unread ON notification_receivers(user_id, is_read) WHERE is_deleted = FALSE;
CREATE UNIQUE INDEX idx_notification_receivers_unique ON notification_receivers(notification_id, user_id);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS notification_deliveries;
@@ -0,0 +1,23 @@
-- Notification deliveries table (tracks per-device delivery attempts)
CREATE TABLE notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_receiver_id UUID NOT NULL REFERENCES notification_receivers(id) ON DELETE CASCADE,
user_device_id UUID NOT NULL REFERENCES user_devices(id) ON DELETE CASCADE,
channel VARCHAR(50) NOT NULL DEFAULT 'push' CHECK (channel IN ('push', 'websocket', 'email')),
delivery_status VARCHAR(50) NOT NULL DEFAULT 'pending' CHECK (delivery_status IN ('pending', 'sent', 'delivered', 'failed')),
provider VARCHAR(50) CHECK (provider IN ('firebase', 'onesignal')),
provider_message_id VARCHAR(255),
sent_at TIMESTAMP WITH TIME ZONE,
delivered_at TIMESTAMP WITH TIME ZONE,
failed_at TIMESTAMP WITH TIME ZONE,
failure_reason TEXT,
retry_count INT NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_notification_deliveries_receiver_id ON notification_deliveries(notification_receiver_id);
CREATE INDEX idx_notification_deliveries_device_id ON notification_deliveries(user_device_id);
CREATE INDEX idx_notification_deliveries_status ON notification_deliveries(delivery_status);
CREATE INDEX idx_notification_deliveries_provider ON notification_deliveries(provider);
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_categories_outlet_id;
ALTER TABLE categories DROP CONSTRAINT IF EXISTS fk_categories_outlet;
ALTER TABLE categories DROP COLUMN IF EXISTS outlet_id;
@@ -0,0 +1,3 @@
ALTER TABLE categories ADD COLUMN outlet_id UUID;
ALTER TABLE categories ADD CONSTRAINT fk_categories_outlet FOREIGN KEY (outlet_id) REFERENCES outlets(id) ON DELETE SET NULL;
CREATE INDEX idx_categories_outlet_id ON categories(outlet_id);