From b993da898f5945cbe80750784f7fdc4a15950d23 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 4 May 2026 16:23:29 +0700 Subject: [PATCH] Self Order --- internal/app/app.go | 14 +- internal/constants/error.go | 1 + internal/constants/order.go | 7 + internal/contract/self_order_contract.go | 51 ++++ internal/entities/order.go | 1 + internal/entities/table.go | 1 + internal/handler/self_order_handler.go | 239 ++++++++++++++++++ internal/middleware/self_order_middleware.go | 53 ++++ internal/repository/customer_repository.go | 12 + internal/repository/table_repository.go | 13 + .../repository/table_repository_interface.go | 1 + internal/repository/user_repository.go | 11 + internal/router/router.go | 14 +- internal/service/order_service_table_test.go | 8 + .../000063_add_self_order_support.down.sql | 7 + .../000063_add_self_order_support.up.sql | 13 + 16 files changed, 443 insertions(+), 3 deletions(-) create mode 100644 internal/contract/self_order_contract.go create mode 100644 internal/handler/self_order_handler.go create mode 100644 internal/middleware/self_order_middleware.go create mode 100644 migrations/000063_add_self_order_support.down.sql create mode 100644 migrations/000063_add_self_order_support.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index 0be0306..34df71e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -42,7 +42,7 @@ func (a *App) Initialize(cfg *config.Config) error { processors := a.initProcessors(cfg, repos) services := a.initServices(processors, repos, cfg) validators := a.initValidators() - middleware := a.initMiddleware(services, cfg) + middleware := a.initMiddleware(services, cfg, repos) healthHandler := handler.NewHealthHandler() a.router = router.NewRouter( @@ -105,6 +105,14 @@ func (a *App) Initialize(cfg *config.Config) error { services.customerPointsService, services.spinGameService, middleware.customerAuthMiddleware, + handler.NewSelfOrderHandler( + services.orderService, + services.productService, + repos.customerRepo, + repos.userRepo, + repos.outletRepo, + ), + middleware.selfOrderMiddleware, ) return nil @@ -441,12 +449,14 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con type middlewares struct { authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware + selfOrderMiddleware *middleware.SelfOrderMiddleware } -func (a *App) initMiddleware(services *services, cfg *config.Config) *middlewares { +func (a *App) initMiddleware(services *services, cfg *config.Config, repos *repositories) *middlewares { return &middlewares{ authMiddleware: middleware.NewAuthMiddleware(services.authService), customerAuthMiddleware: middleware.NewCustomerAuthMiddleware(cfg.GetCustomerJWTSecret()), + selfOrderMiddleware: middleware.NewSelfOrderMiddleware(repos.tableRepo), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index 002ac43..e194381 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -56,6 +56,7 @@ const ( CampaignRuleEntity = "campaign_rule" CustomerEntity = "customer" SpinGameHandlerEntity = "spin_game_handler" + SelfOrderEntity = "self_order" ) var HttpErrorMap = map[string]int{ diff --git a/internal/constants/order.go b/internal/constants/order.go index 2982419..a2f05e8 100644 --- a/internal/constants/order.go +++ b/internal/constants/order.go @@ -60,6 +60,13 @@ func GetAllOrderItemStatuses() []OrderItemStatus { } } +type OrderSource string + +const ( + OrderSourceStaff OrderSource = "staff" + OrderSourceSelfOrder OrderSource = "self_order" +) + func (o OrderType) IsValidOrderType() bool { for _, validType := range GetAllOrderTypes() { if o == validType { diff --git a/internal/contract/self_order_contract.go b/internal/contract/self_order_contract.go new file mode 100644 index 0000000..1a2c863 --- /dev/null +++ b/internal/contract/self_order_contract.go @@ -0,0 +1,51 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateSelfOrderRequest struct { + CustomerName string `json:"customer_name" validate:"required,min=1,max=255"` + PhoneNumber *string `json:"phone_number,omitempty" validate:"omitempty"` + OrderItems []SelfOrderItemRequest `json:"order_items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000"` +} + +type SelfOrderItemRequest 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" validate:"omitempty,max=500"` +} + +type SelfOrderResponse struct { + OrderID uuid.UUID `json:"order_id"` + OrderNumber string `json:"order_number"` + TableID uuid.UUID `json:"table_id"` + TableName string `json:"table_name"` + OutletID uuid.UUID `json:"outlet_id"` + OutletName string `json:"outlet_name"` + CustomerName string `json:"customer_name"` + OrderItems []OrderItemResponse `json:"order_items"` + Subtotal float64 `json:"subtotal"` + TaxAmount float64 `json:"tax_amount"` + TotalAmount float64 `json:"total_amount"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type SelfOrderMenuResponse struct { + OutletID uuid.UUID `json:"outlet_id"` + OutletName string `json:"outlet_name"` + TableID uuid.UUID `json:"table_id"` + TableName string `json:"table_name"` + Organization OrganizationMenuInfo `json:"organization"` + Products ListProductsResponse `json:"products"` +} + +type OrganizationMenuInfo struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} diff --git a/internal/entities/order.go b/internal/entities/order.go index 137f70f..0368d84 100644 --- a/internal/entities/order.go +++ b/internal/entities/order.go @@ -53,6 +53,7 @@ type Order struct { RemainingAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"remaining_amount"` PaymentStatus PaymentStatus `gorm:"default:'pending';size:50" json:"payment_status"` RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"` + Source string `gorm:"default:'staff';size:50" json:"source"` IsVoid bool `gorm:"default:false" json:"is_void"` IsRefund bool `gorm:"default:false" json:"is_refund"` VoidReason *string `gorm:"size:255" json:"void_reason,omitempty"` diff --git a/internal/entities/table.go b/internal/entities/table.go index 0468879..8f56961 100644 --- a/internal/entities/table.go +++ b/internal/entities/table.go @@ -19,6 +19,7 @@ type Table struct { PositionX float64 `gorm:"type:decimal(10,2);default:0.00" json:"position_x"` PositionY float64 `gorm:"type:decimal(10,2);default:0.00" json:"position_y"` Capacity int `gorm:"default:4" json:"capacity"` + Token string `gorm:"uniqueIndex;size:100" json:"token"` IsActive bool `gorm:"default:true" json:"is_active"` Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` diff --git a/internal/handler/self_order_handler.go b/internal/handler/self_order_handler.go new file mode 100644 index 0000000..96e794e --- /dev/null +++ b/internal/handler/self_order_handler.go @@ -0,0 +1,239 @@ +package handler + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type SelfOrderHandler struct { + orderService service.OrderService + productService service.ProductService + customerRepo *repository.CustomerRepository + userRepo *repository.UserRepositoryImpl + outletRepo *repository.OutletRepositoryImpl +} + +func NewSelfOrderHandler( + orderService service.OrderService, + productService service.ProductService, + customerRepo *repository.CustomerRepository, + userRepo *repository.UserRepositoryImpl, + outletRepo *repository.OutletRepositoryImpl, +) *SelfOrderHandler { + return &SelfOrderHandler{ + orderService: orderService, + productService: productService, + customerRepo: customerRepo, + userRepo: userRepo, + outletRepo: outletRepo, + } +} + +func (h *SelfOrderHandler) GetMenu(c *gin.Context) { + ctx := c.Request.Context() + + organizationIDStr, _ := c.Get("self_order_organization_id") + outletIDStr, _ := c.Get("self_order_outlet_id") + tableIDStr, _ := c.Get("self_order_table_id") + tableName, _ := c.Get("self_order_table_name") + + organizationID, err := uuid.Parse(organizationIDStr.(string)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.SelfOrderEntity, "invalid organization ID"), + }), "SelfOrderHandler::GetMenu") + return + } + + outletID, err := uuid.Parse(outletIDStr.(string)) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.SelfOrderEntity, "invalid outlet ID"), + }), "SelfOrderHandler::GetMenu") + return + } + + tableID, _ := uuid.Parse(tableIDStr.(string)) + + isActive := true + req := &contract.ListProductsRequest{ + OrganizationID: &organizationID, + IsActive: &isActive, + Page: 1, + Limit: 100, + } + + productsResponse := h.productService.ListProducts(ctx, req) + + outlet, outletErr := h.outletRepo.GetByID(ctx, outletID) + outletName := "" + if outletErr == nil && outlet != nil { + outletName = outlet.Name + } + + menuResponse := &contract.SelfOrderMenuResponse{ + OutletID: outletID, + OutletName: outletName, + TableID: tableID, + TableName: tableName.(string), + Organization: contract.OrganizationMenuInfo{ + ID: organizationID, + }, + } + + if productsResponse != nil { + if data, ok := productsResponse.Data.(*contract.ListProductsResponse); ok { + menuResponse.Products = *data + } + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menuResponse), "SelfOrderHandler::GetMenu") +} + +func (h *SelfOrderHandler) CreateOrder(c *gin.Context) { + ctx := c.Request.Context() + + organizationIDStr, _ := c.Get("self_order_organization_id") + outletIDStr, _ := c.Get("self_order_outlet_id") + tableIDStr, _ := c.Get("self_order_table_id") + tableName, _ := c.Get("self_order_table_name") + + organizationID, _ := uuid.Parse(organizationIDStr.(string)) + outletID, _ := uuid.Parse(outletIDStr.(string)) + tableID, _ := uuid.Parse(tableIDStr.(string)) + + var req contract.CreateSelfOrderRequest + 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 req.CustomerName == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.SelfOrderEntity, "customer_name is required"), + }), "SelfOrderHandler::CreateOrder") + return + } + + if len(req.OrderItems) == 0 { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.SelfOrderEntity, "at least one order item is required"), + }), "SelfOrderHandler::CreateOrder") + return + } + + adminUser, err := h.userRepo.GetAdminByOrganizationID(ctx, organizationID) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to get admin user") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.SelfOrderEntity, "failed to resolve system user"), + }), "SelfOrderHandler::CreateOrder") + return + } + + var customerID *uuid.UUID + if req.PhoneNumber != nil && *req.PhoneNumber != "" { + customer, err := h.customerRepo.GetByPhoneNumber(ctx, *req.PhoneNumber) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("SelfOrderHandler::CreateOrder -> failed to lookup customer by phone") + } + if customer != nil { + customerID = &customer.ID + } + } + + metadata := map[string]interface{}{ + "source": string(constants.OrderSourceSelfOrder), + "customer_phone": "", + } + if req.PhoneNumber != nil { + metadata["customer_phone"] = *req.PhoneNumber + } + + orderItems := make([]models.CreateOrderItemRequest, len(req.OrderItems)) + for i, item := range req.OrderItems { + orderItems[i] = models.CreateOrderItemRequest{ + ProductID: item.ProductID, + ProductVariantID: item.ProductVariantID, + Quantity: item.Quantity, + Notes: item.Notes, + } + } + + tableNameStr := tableName.(string) + modelReq := &models.CreateOrderRequest{ + OutletID: outletID, + UserID: adminUser.ID, + CustomerID: customerID, + TableID: &tableID, + TableNumber: &tableNameStr, + OrderType: constants.OrderTypeDineIn, + OrderItems: orderItems, + Notes: req.Notes, + CustomerName: &req.CustomerName, + Metadata: metadata, + } + + response, err := h.orderService.CreateOrder(ctx, modelReq, 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.SelfOrderEntity, err.Error()), + }), "SelfOrderHandler::CreateOrder") + return + } + + outlet, _ := h.outletRepo.GetByID(ctx, outletID) + outletName := "" + if outlet != nil { + outletName = outlet.Name + } + + orderItemsResp := make([]contract.OrderItemResponse, len(response.OrderItems)) + for i, item := range response.OrderItems { + orderItemsResp[i] = contract.OrderItemResponse{ + ID: item.ID, + OrderID: item.OrderID, + ProductID: item.ProductID, + ProductName: item.ProductName, + ProductVariantID: item.ProductVariantID, + ProductVariantName: item.ProductVariantName, + Quantity: item.Quantity, + UnitPrice: item.UnitPrice, + TotalPrice: item.TotalPrice, + Notes: item.Notes, + Status: string(item.Status), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + } + } + + selfOrderResp := &contract.SelfOrderResponse{ + OrderID: response.ID, + OrderNumber: response.OrderNumber, + TableID: tableID, + TableName: tableNameStr, + OutletID: outletID, + OutletName: outletName, + CustomerName: req.CustomerName, + OrderItems: orderItemsResp, + Subtotal: response.Subtotal, + TaxAmount: response.TaxAmount, + TotalAmount: response.TotalAmount, + Status: string(response.Status), + CreatedAt: response.CreatedAt, + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(selfOrderResp), "SelfOrderHandler::CreateOrder") +} diff --git a/internal/middleware/self_order_middleware.go b/internal/middleware/self_order_middleware.go new file mode 100644 index 0000000..19f40eb --- /dev/null +++ b/internal/middleware/self_order_middleware.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/util" + + "github.com/gin-gonic/gin" +) + +type SelfOrderMiddleware struct { + tableRepo repository.TableRepositoryInterface +} + +func NewSelfOrderMiddleware(tableRepo repository.TableRepositoryInterface) *SelfOrderMiddleware { + return &SelfOrderMiddleware{ + tableRepo: tableRepo, + } +} + +func (m *SelfOrderMiddleware) ResolveToken() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.Query("token") + if token == "" { + token = c.GetHeader("X-Table-Token") + } + + if token == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.SelfOrderEntity, "token is required"), + }), "SelfOrderMiddleware::ResolveToken") + c.Abort() + return + } + + table, err := m.tableRepo.GetByToken(c.Request.Context(), token) + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.NotFoundErrorCode, constants.SelfOrderEntity, "invalid or expired table token"), + }), "SelfOrderMiddleware::ResolveToken") + c.Abort() + return + } + + c.Set("self_order_table_id", table.ID.String()) + c.Set("self_order_table_name", table.TableName) + c.Set("self_order_outlet_id", table.OutletID.String()) + c.Set("self_order_organization_id", table.OrganizationID.String()) + + c.Next() + } +} diff --git a/internal/repository/customer_repository.go b/internal/repository/customer_repository.go index a92f0f4..ad13388 100644 --- a/internal/repository/customer_repository.go +++ b/internal/repository/customer_repository.go @@ -138,3 +138,15 @@ func (r *CustomerRepository) GetByEmail(ctx context.Context, email string, organ } return &customer, nil } + +func (r *CustomerRepository) GetByPhoneNumber(ctx context.Context, phoneNumber string) (*entities.Customer, error) { + var customer entities.Customer + err := r.db.WithContext(ctx).Where("phone_number = ?", phoneNumber).First(&customer).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &customer, nil +} diff --git a/internal/repository/table_repository.go b/internal/repository/table_repository.go index 4a7a782..c695df4 100644 --- a/internal/repository/table_repository.go +++ b/internal/repository/table_repository.go @@ -170,3 +170,16 @@ func (r *TableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUID) ( } 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"). + Where("token = ? AND is_active = ?", token, true). + First(&table).Error + if err != nil { + return nil, err + } + return &table, nil +} diff --git a/internal/repository/table_repository_interface.go b/internal/repository/table_repository_interface.go index a405482..cb6507e 100644 --- a/internal/repository/table_repository_interface.go +++ b/internal/repository/table_repository_interface.go @@ -23,4 +23,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) + GetByToken(ctx context.Context, token string) (*entities.Table, error) } diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index a9e2747..0480703 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -99,6 +99,17 @@ func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interf return users, total, err } +func (r *UserRepositoryImpl) GetAdminByOrganizationID(ctx context.Context, organizationID uuid.UUID) (*entities.User, error) { + var user entities.User + err := r.db.WithContext(ctx). + Where("organization_id = ? AND role = ? AND is_active = ?", organizationID, entities.RoleAdmin, true). + First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { var count int64 query := r.db.WithContext(ctx).Model(&entities.User{}) diff --git a/internal/router/router.go b/internal/router/router.go index 287ba3f..75d309f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -46,11 +46,13 @@ type Router struct { customerAuthHandler *handler.CustomerAuthHandler customerPointsHandler *handler.CustomerPointsHandler spinGameHandler *handler.SpinGameHandler + selfOrderHandler *handler.SelfOrderHandler authMiddleware *middleware.AuthMiddleware customerAuthMiddleware *middleware.CustomerAuthMiddleware + selfOrderMiddleware *middleware.SelfOrderMiddleware } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, selfOrderHandler *handler.SelfOrderHandler, selfOrderMiddleware *middleware.SelfOrderMiddleware) *Router { return &Router{ config: cfg, @@ -88,6 +90,8 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer spinGameHandler: handler.NewSpinGameHandler(spinGameService), authMiddleware: authMiddleware, customerAuthMiddleware: customerAuthMiddleware, + selfOrderHandler: selfOrderHandler, + selfOrderMiddleware: selfOrderMiddleware, productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator), } } @@ -145,6 +149,14 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/spin", r.spinGameHandler.PlaySpinGame) } + // Self-order routes (public, token-based table identification) + selfOrder := v1.Group("/self-order") + selfOrder.Use(r.selfOrderMiddleware.ResolveToken()) + { + selfOrder.GET("/menu", r.selfOrderHandler.GetMenu) + selfOrder.POST("/order", r.selfOrderHandler.CreateOrder) + } + organizations := v1.Group("/organizations") { organizations.POST("", r.organizationHandler.CreateOrganization) diff --git a/internal/service/order_service_table_test.go b/internal/service/order_service_table_test.go index e961712..fb1ba4a 100644 --- a/internal/service/order_service_table_test.go +++ b/internal/service/order_service_table_test.go @@ -182,6 +182,14 @@ func (m *MockTableRepository) GetByOrderID(ctx context.Context, orderID uuid.UUI return args.Get(0).(*entities.Table), args.Error(1) } +func (m *MockTableRepository) GetByToken(ctx context.Context, token string) (*entities.Table, error) { + args := m.Called(ctx, token) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*entities.Table), args.Error(1) +} + func TestCreateOrderWithTableOccupation(t *testing.T) { // Setup ctx := context.Background() diff --git a/migrations/000063_add_self_order_support.down.sql b/migrations/000063_add_self_order_support.down.sql new file mode 100644 index 0000000..c5174e5 --- /dev/null +++ b/migrations/000063_add_self_order_support.down.sql @@ -0,0 +1,7 @@ +-- Remove source column from orders +ALTER TABLE orders DROP COLUMN IF EXISTS source; + +-- Remove token column from tables +DROP INDEX IF EXISTS idx_tables_token_active; +DROP INDEX IF EXISTS idx_tables_token; +ALTER TABLE tables DROP COLUMN IF EXISTS token; diff --git a/migrations/000063_add_self_order_support.up.sql b/migrations/000063_add_self_order_support.up.sql new file mode 100644 index 0000000..1455dbb --- /dev/null +++ b/migrations/000063_add_self_order_support.up.sql @@ -0,0 +1,13 @@ +-- Add token column to tables for self-order QR code identification +ALTER TABLE tables ADD COLUMN IF NOT EXISTS token VARCHAR(100); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tables_token ON tables(token); +CREATE INDEX IF NOT EXISTS idx_tables_token_active ON tables(token) WHERE is_active = true; + +-- Backfill existing tables with unique tokens +-- Uses gen_random_uuid() to generate unique tokens for each existing table +UPDATE tables SET token = gen_random_uuid()::text WHERE token IS NULL; + +-- Make token NOT NULL after backfill (optional, keep nullable for flexibility) + +-- Add source column to orders for tracking order origin +ALTER TABLE orders ADD COLUMN IF NOT EXISTS source VARCHAR(50) DEFAULT 'staff';