diff --git a/cmd/server/main.go b/cmd/server/main.go index d99e6f0..2e492bb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/config/configs.go b/config/configs.go index 6a8dfe0..170d32d 100644 --- a/config/configs.go +++ b/config/configs.go @@ -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"` diff --git a/config/redis.go b/config/redis.go new file mode 100644 index 0000000..d12f8c2 --- /dev/null +++ b/config/redis.go @@ -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 +} diff --git a/go.mod b/go.mod index 3539fbb..e502de5 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module apskel-pos-be -go 1.21 +go 1.24 require ( github.com/gin-gonic/gin v1.9.1 @@ -13,6 +13,7 @@ require ( require ( github.com/bytedance/sonic v1.10.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect github.com/chenzhuoyu/iasm v0.9.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -31,7 +32,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 @@ -49,11 +50,11 @@ require ( github.com/subosito/gotenv v1.4.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - go.uber.org/atomic v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.8.0 // indirect golang.org/x/arch v0.7.0 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.26.0 // indirect + golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.32.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect @@ -63,6 +64,7 @@ require ( require ( github.com/aws/aws-sdk-go v1.55.7 github.com/golang-jwt/jwt/v5 v5.2.3 + github.com/redis/go-redis/v9 v9.19.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.4 go.uber.org/zap v1.21.0 diff --git a/go.sum b/go.sum index 6cc295c..67735e8 100644 --- a/go.sum +++ b/go.sum @@ -42,11 +42,17 @@ 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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= @@ -181,8 +187,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= @@ -218,6 +224,8 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= @@ -261,6 +269,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -268,8 +278,8 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.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/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -428,8 +438,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/infra/development.yaml b/infra/development.yaml index 6880e35..dc1f891 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -27,6 +27,17 @@ postgresql: connection-max-life-time-in-second: 600 debug: false +redis: + host: 127.0.0.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 diff --git a/internal/app/app.go b/internal/app/app.go index 1596bb2..e466e6e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -20,20 +20,23 @@ import ( "apskel-pos-be/internal/service" "apskel-pos-be/internal/validator" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) type App struct { - server *http.Server - db *gorm.DB - router *router.Router - shutdown chan os.Signal + server *http.Server + db *gorm.DB + redisClient *redis.Client + router *router.Router + shutdown chan os.Signal } -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), } } @@ -51,6 +54,7 @@ func (a *App) Initialize(cfg *config.Config) error { repos.tableRepo, repos.outletRepo, repos.userRepo, + repos.sessionRepo, ) a.router = router.NewRouter( @@ -200,6 +204,7 @@ type repositories struct { customerAuthRepo repository.CustomerAuthRepository customerPointsRepo repository.CustomerPointsRepository otpRepo repository.OtpRepository + sessionRepo repository.SessionRepository txManager *repository.TxManager } @@ -246,6 +251,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), } } @@ -384,7 +390,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) // 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) @@ -409,7 +415,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager) // 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) return &services{ userService: service.NewUserService(processors.userProcessor), diff --git a/internal/contract/self_order_contract.go b/internal/contract/self_order_contract.go index 0b10f88..69c6096 100644 --- a/internal/contract/self_order_contract.go +++ b/internal/contract/self_order_contract.go @@ -4,10 +4,18 @@ 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 { - TableID uuid.UUID `json:"table_id" validate:"required"` - CustomerName string `json:"customer_name" validate:"required"` - Phone *string `json:"phone,omitempty"` + SessionID string `json:"session_id" validate:"required"` } type SelfOrderMenuResponse struct { @@ -40,10 +48,8 @@ type SelfOrderMenuVariant struct { } type SelfOrderCreateOrderRequest struct { - TableID uuid.UUID `json:"table_id" validate:"required"` - CustomerName string `json:"customer_name" validate:"required"` - Phone *string `json:"phone,omitempty"` - OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"` + SessionID string `json:"session_id" validate:"required"` + OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"` } type SelfOrderCreateOrderItem struct { @@ -54,7 +60,7 @@ type SelfOrderCreateOrderItem struct { } type SelfOrderListCategoriesRequest struct { - TableID string `form:"table_id" validate:"required"` + SessionID string `form:"session_id" validate:"required"` } type SelfOrderCategoryItem struct { diff --git a/internal/db/redis.go b/internal/db/redis.go new file mode 100644 index 0000000..9748f2e --- /dev/null +++ b/internal/db/redis.go @@ -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 +} diff --git a/internal/entities/table.go b/internal/entities/table.go index 0468879..c16f06c 100644 --- a/internal/entities/table.go +++ b/internal/entities/table.go @@ -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 } diff --git a/internal/handler/self_order_handler.go b/internal/handler/self_order_handler.go index d806d87..8c5a709 100644 --- a/internal/handler/self_order_handler.go +++ b/internal/handler/self_order_handler.go @@ -6,6 +6,7 @@ import ( "apskel-pos-be/internal/entities" "apskel-pos-be/internal/logger" "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" @@ -25,6 +26,7 @@ type SelfOrderHandler struct { tableRepo repository.TableRepositoryInterface outletRepo processor.OutletRepository userRepo processor.UserRepository + sessionRepo repository.SessionRepository } func NewSelfOrderHandler( @@ -34,6 +36,7 @@ func NewSelfOrderHandler( tableRepo repository.TableRepositoryInterface, outletRepo processor.OutletRepository, userRepo processor.UserRepository, + sessionRepo repository.SessionRepository, ) *SelfOrderHandler { return &SelfOrderHandler{ orderService: orderService, @@ -42,9 +45,107 @@ func NewSelfOrderHandler( tableRepo: tableRepo, outletRepo: outletRepo, userRepo: userRepo, + sessionRepo: sessionRepo, } } +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() @@ -57,41 +158,16 @@ func (h *SelfOrderHandler) GetMenu(c *gin.Context) { return } - if req.TableID == uuid.Nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "table_id is required"), - }), "SelfOrderHandler::GetMenu") - return - } - - if req.CustomerName == "" { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "customer_name is required"), - }), "SelfOrderHandler::GetMenu") - return - } - - table, err := h.tableRepo.GetByID(ctx, req.TableID) + session, table, outlet, err := h.resolveSession(ctx, req.SessionID) if err != nil { - logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetMenu -> table not found") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"), + contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()), }), "SelfOrderHandler::GetMenu") return } - - if !table.IsActive { + if session == nil { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.TableEntity, "table is not active"), - }), "SelfOrderHandler::GetMenu") - return - } - - outlet, err := h.outletRepo.GetByID(ctx, table.OutletID) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::GetMenu -> outlet not found") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.NotFoundErrorCode, constants.OrderServiceEntity, "outlet not found"), + contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found or expired"), }), "SelfOrderHandler::GetMenu") return } @@ -208,11 +284,16 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { return } - table, err := h.tableRepo.GetByID(ctx, req.TableID) + session, table, _, err := h.resolveSession(ctx, req.SessionID) if err != nil { - logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> table not found") util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"), + 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 } @@ -245,21 +326,17 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { metadata := make(map[string]interface{}) metadata["self_order"] = true - metadata["customer_name"] = req.CustomerName - if req.Phone != nil { - metadata["customer_phone"] = *req.Phone - } + metadata["session_id"] = session.ID - tableID := req.TableID + tableID := table.ID modelReq := &models.CreateOrderRequest{ - OutletID: table.OutletID, - UserID: userID, - TableID: &tableID, - TableNumber: &table.TableName, - OrderType: constants.OrderTypeDineIn, - OrderItems: orderItems, - CustomerName: &req.CustomerName, - Metadata: metadata, + OutletID: table.OutletID, + UserID: userID, + TableID: &tableID, + TableNumber: &table.TableName, + OrderType: constants.OrderTypeDineIn, + OrderItems: orderItems, + Metadata: metadata, } response, err := h.orderService.CreateOrder(ctx, modelReq, table.OrganizationID) @@ -276,11 +353,8 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { } func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCreateOrderRequest) error { - if req.TableID == uuid.Nil { - return fmt.Errorf("table_id is required") - } - if req.CustomerName == "" { - return fmt.Errorf("customer_name is required") + 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") @@ -308,26 +382,23 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) { return } - if req.TableID == "" { + if req.SessionID == "" { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "table_id is required"), + contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "session_id is required"), }), "SelfOrderHandler::ListCategories") return } - parsedTableID, err := uuid.Parse(req.TableID) + 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, "table_id must be a valid UUID"), + contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, err.Error()), }), "SelfOrderHandler::ListCategories") return } - - table, err := h.tableRepo.GetByID(ctx, parsedTableID) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::ListCategories -> table not found") + if session == nil { util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.NotFoundErrorCode, constants.TableEntity, "table not found"), + contract.NewResponseError(constants.NotFoundErrorCode, constants.RequestEntity, "session not found or expired"), }), "SelfOrderHandler::ListCategories") return } @@ -366,6 +437,31 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) { }), "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 { diff --git a/internal/models/session.go b/internal/models/session.go new file mode 100644 index 0000000..12c9189 --- /dev/null +++ b/internal/models/session.go @@ -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"` +} diff --git a/internal/pkg/tabletoken/token.go b/internal/pkg/tabletoken/token.go new file mode 100644 index 0000000..bd8b67d --- /dev/null +++ b/internal/pkg/tabletoken/token.go @@ -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 +} diff --git a/internal/repository/session_repository.go b/internal/repository/session_repository.go new file mode 100644 index 0000000..6c283ac --- /dev/null +++ b/internal/repository/session_repository.go @@ -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) +} diff --git a/internal/repository/table_repository.go b/internal/repository/table_repository.go index 4a7a782..add78c8 100644 --- a/internal/repository/table_repository.go +++ b/internal/repository/table_repository.go @@ -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). diff --git a/internal/repository/table_repository_interface.go b/internal/repository/table_repository_interface.go index a405482..33f1367 100644 --- a/internal/repository/table_repository_interface.go +++ b/internal/repository/table_repository_interface.go @@ -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 diff --git a/internal/router/router.go b/internal/router/router.go index 9a11c20..726b12f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -149,9 +149,10 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { selfOrder := v1.Group("/self-order") { + selfOrder.GET("/table/:token", r.selfOrderHandler.ValidateToken) selfOrder.GET("/categories", r.selfOrderHandler.ListCategories) selfOrder.POST("/menu", r.selfOrderHandler.GetMenu) - selfOrder.POST("/order", r.selfOrderHandler.CreateOrder) + selfOrder.POST("/orders", r.selfOrderHandler.CreateOrder) } organizations := v1.Group("/organizations") diff --git a/internal/service/order_service.go b/internal/service/order_service.go index 33d334a..056fc99 100644 --- a/internal/service/order_service.go +++ b/internal/service/order_service.go @@ -37,9 +37,10 @@ type OrderServiceImpl struct { orderIngredientTransactionProcessor processor.OrderIngredientTransactionProcessor productRecipeRepo repository.ProductRecipeRepository txManager *repository.TxManager + sessionRepo repository.SessionRepository } -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) *OrderServiceImpl { return &OrderServiceImpl{ orderProcessor: orderProcessor, tableRepo: tableRepo, @@ -47,6 +48,7 @@ func NewOrderServiceImpl(orderProcessor processor.OrderProcessor, tableRepo repo orderIngredientTransactionProcessor: orderIngredientTransactionProcessor, productRecipeRepo: productRecipeRepo, txManager: txManager, + sessionRepo: sessionRepo, } } @@ -621,6 +623,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) + } + } } } diff --git a/migrations/000063_add_token_to_tables.down.sql b/migrations/000063_add_token_to_tables.down.sql new file mode 100644 index 0000000..b9b1d2b --- /dev/null +++ b/migrations/000063_add_token_to_tables.down.sql @@ -0,0 +1 @@ +ALTER TABLE tables DROP COLUMN IF EXISTS token; diff --git a/migrations/000063_add_token_to_tables.up.sql b/migrations/000063_add_token_to_tables.up.sql new file mode 100644 index 0000000..6c4256c --- /dev/null +++ b/migrations/000063_add_token_to_tables.up.sql @@ -0,0 +1 @@ +ALTER TABLE tables ADD COLUMN token VARCHAR(255) UNIQUE NOT NULL DEFAULT gen_random_uuid()::text;