diff --git a/internal/app/app.go b/internal/app/app.go index 6b224ab..3dbbcda 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -94,6 +94,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.accountValidator, *services.orderIngredientTransactionService, validators.orderIngredientTransactionValidator, + services.voucherService, + validators.voucherValidator, ) return nil @@ -167,6 +169,7 @@ type repositories struct { chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl + voucherRepo *repository.VoucherRepository txManager *repository.TxManager } @@ -200,7 +203,8 @@ func (a *App) initRepositories() *repositories { chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db), accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), - txManager: repository.NewTxManager(a.db), + voucherRepo: repository.NewVoucherRepository(a.db), + txManager: repository.NewTxManager(a.db), } } @@ -229,6 +233,7 @@ type processors struct { chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl + voucherProcessor *processor.VoucherProcessor fileClient processor.FileClient inventoryMovementService service.InventoryMovementService } @@ -262,6 +267,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo), accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), + voucherProcessor: processor.NewVoucherProcessor(repos.voucherRepo), fileClient: fileClient, inventoryMovementService: inventoryMovementService, } @@ -294,6 +300,7 @@ type services struct { chartOfAccountService service.ChartOfAccountService accountService service.AccountService orderIngredientTransactionService *service.OrderIngredientTransactionService + voucherService service.VoucherService } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -324,6 +331,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) + voucherService := service.NewVoucherService(processors.voucherProcessor) // Update order service with order ingredient transaction service orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) @@ -355,6 +363,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService: chartOfAccountService, accountService: accountService, orderIngredientTransactionService: orderIngredientTransactionService, + voucherService: voucherService, } } @@ -388,6 +397,7 @@ type validators struct { chartOfAccountValidator *validator.ChartOfAccountValidatorImpl accountValidator *validator.AccountValidatorImpl orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl + voucherValidator validator.VoucherValidator } func (a *App) initValidators() *validators { @@ -411,5 +421,6 @@ func (a *App) initValidators() *validators { chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl), accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl), orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl), + voucherValidator: validator.NewVoucherValidator(), } } diff --git a/internal/contract/voucher_contract.go b/internal/contract/voucher_contract.go new file mode 100644 index 0000000..6f797e8 --- /dev/null +++ b/internal/contract/voucher_contract.go @@ -0,0 +1,51 @@ +package contract + +type VoucherResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + VoucherCode string `json:"voucher_code"` + WinnerNumber int `json:"winner_number"` + IsWinner bool `json:"is_winner"` +} + +type VoucherSpinResponse struct { + VoucherCode string `json:"voucher_code"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` // This will be masked + IsWinner bool `json:"is_winner"` +} + +type ListVouchersForSpinRequest struct { + Limit int `json:"limit" validate:"min=1,max=50"` +} + +type ListVouchersForSpinResponse struct { + Vouchers []VoucherSpinResponse `json:"vouchers"` + Count int `json:"count"` +} + +type PaginatedVoucherResponse struct { + Data []VoucherResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +type VoucherRow struct { + RowNumber int `json:"row_number"` + Vouchers []VoucherSpinResponse `json:"vouchers"` +} + +type ListVouchersByRowsRequest struct { + Rows int `json:"rows" validate:"min=1,max=10"` + WinnerNumber *int `json:"winner_number,omitempty"` +} + +type ListVouchersByRowsResponse struct { + Rows []VoucherRow `json:"rows"` + TotalRows int `json:"total_rows"` + TotalVouchers int `json:"total_vouchers"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 1805974..b79c634 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -23,6 +23,7 @@ func GetAllEntities() []interface{} { &PurchaseOrderItem{}, &PurchaseOrderAttachment{}, &IngredientUnitConverter{}, + &Voucher{}, // Analytics entities are not database tables, they are query results } } diff --git a/internal/entities/voucher.go b/internal/entities/voucher.go new file mode 100644 index 0000000..0866f3a --- /dev/null +++ b/internal/entities/voucher.go @@ -0,0 +1,15 @@ +package entities + +type Voucher struct { + ID int64 `gorm:"type:bigserial;primary_key;autoIncrement" json:"id"` + Name string `gorm:"not null;size:100" json:"name" validate:"required"` + Email string `gorm:"not null;size:255" json:"email" validate:"required"` + PhoneNumber string `gorm:"not null;size:20" json:"phone_number" validate:"required"` + VoucherCode string `gorm:"not null;size:50" json:"voucher_code" validate:"required"` + WinnerNumber int `gorm:"not null;default:0" json:"winner_number" validate:"required"` + IsWinner bool `gorm:"not null;default:false" json:"is_winner"` +} + +func (Voucher) TableName() string { + return "vouchers" +} diff --git a/internal/handler/voucher_handler.go b/internal/handler/voucher_handler.go new file mode 100644 index 0000000..4c44e25 --- /dev/null +++ b/internal/handler/voucher_handler.go @@ -0,0 +1,177 @@ +package handler + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + "apskel-pos-be/internal/validator" + "strconv" + + "github.com/gin-gonic/gin" +) + +type VoucherHandler struct { + voucherService service.VoucherService + voucherValidator validator.VoucherValidator +} + +func NewVoucherHandler( + voucherService service.VoucherService, + voucherValidator validator.VoucherValidator, +) *VoucherHandler { + return &VoucherHandler{ + voucherService: voucherService, + voucherValidator: voucherValidator, + } +} + +func (h *VoucherHandler) GetRandomVouchersForSpin(c *gin.Context) { + ctx := c.Request.Context() + + req := &contract.ListVouchersForSpinRequest{ + Limit: 10, // Default limit + } + + // Parse query parameters + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil { + req.Limit = limit + } + } + + validationError, validationErrorCode := h.voucherValidator.ValidateListVouchersForSpinRequest(req) + if validationError != nil { + logger.FromContext(ctx).WithError(validationError).Error("VoucherHandler::GetRandomVouchersForSpin -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "VoucherHandler::GetRandomVouchersForSpin") + return + } + + vouchersResponse, err := h.voucherService.GetRandomVouchersForSpin(c.Request.Context(), req) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::GetRandomVouchersForSpin -> Failed to get random vouchers from service") + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{errorResp}), "VoucherHandler::GetRandomVouchersForSpin") + return + } + + logger.FromContext(ctx).Infof("VoucherHandler::GetRandomVouchersForSpin -> Successfully retrieved %d vouchers for spin", vouchersResponse.Count) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(vouchersResponse), "VoucherHandler::GetRandomVouchersForSpin") +} + +func (h *VoucherHandler) GetRandomVouchersByRows(c *gin.Context) { + ctx := c.Request.Context() + + req := &contract.ListVouchersByRowsRequest{ + Rows: 4, // Default 4 rows + } + + // Parse query parameters + if rowsStr := c.Query("rows"); rowsStr != "" { + if rows, err := strconv.Atoi(rowsStr); err == nil { + req.Rows = rows + } + } + + // Parse winner_number parameter (optional) + if winnerNumberStr := c.Query("winner_number"); winnerNumberStr != "" { + if wn, err := strconv.Atoi(winnerNumberStr); err == nil { + req.WinnerNumber = &wn + } + } + + validationError, validationErrorCode := h.voucherValidator.ValidateListVouchersByRowsRequest(req) + if validationError != nil { + logger.FromContext(ctx).WithError(validationError).Error("VoucherHandler::GetRandomVouchersByRows -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "VoucherHandler::GetRandomVouchersByRows") + return + } + + vouchersResponse, err := h.voucherService.GetRandomVouchersByRows(c.Request.Context(), req) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::GetRandomVouchersByRows -> Failed to get random vouchers by rows from service") + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{errorResp}), "VoucherHandler::GetRandomVouchersByRows") + return + } + + logger.FromContext(ctx).Infof("VoucherHandler::GetRandomVouchersByRows -> Successfully retrieved %d rows with %d total vouchers", vouchersResponse.TotalRows, vouchersResponse.TotalVouchers) + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(vouchersResponse), "VoucherHandler::GetRandomVouchersByRows") +} + +func (h *VoucherHandler) GetVoucherByID(c *gin.Context) { + ctx := c.Request.Context() + + voucherID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::GetVoucherByID -> Invalid voucher ID") + validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid voucher ID") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "VoucherHandler::GetVoucherByID") + return + } + + voucherResponse, err := h.voucherService.GetVoucherByID(c.Request.Context(), voucherID) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::GetVoucherByID -> Failed to get voucher from service") + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{errorResp}), "VoucherHandler::GetVoucherByID") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(voucherResponse), "VoucherHandler::GetVoucherByID") +} + +func (h *VoucherHandler) GetVoucherByCode(c *gin.Context) { + ctx := c.Request.Context() + + voucherCode := c.Param("code") + if voucherCode == "" { + logger.FromContext(ctx).Error("VoucherHandler::GetVoucherByCode -> Missing voucher code") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Voucher code is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "VoucherHandler::GetVoucherByCode") + return + } + + voucherResponse, err := h.voucherService.GetVoucherByCode(c.Request.Context(), voucherCode) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::GetVoucherByCode -> Failed to get voucher from service") + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{errorResp}), "VoucherHandler::GetVoucherByCode") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(voucherResponse), "VoucherHandler::GetVoucherByCode") +} + +func (h *VoucherHandler) ListVouchers(c *gin.Context) { + ctx := c.Request.Context() + + page := 1 + limit := 10 + + // Parse query parameters + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + vouchersResponse, err := h.voucherService.ListVouchers(c.Request.Context(), page, limit) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("VoucherHandler::ListVouchers -> Failed to list vouchers from service") + errorResp := contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{errorResp}), "VoucherHandler::ListVouchers") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(vouchersResponse), "VoucherHandler::ListVouchers") +} diff --git a/internal/mappers/voucher_mapper.go b/internal/mappers/voucher_mapper.go new file mode 100644 index 0000000..fdf85fc --- /dev/null +++ b/internal/mappers/voucher_mapper.go @@ -0,0 +1,120 @@ +package mappers + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + "strings" +) + +func VoucherEntityToResponse(voucher *entities.Voucher) *models.VoucherResponse { + return &models.VoucherResponse{ + ID: voucher.ID, + Name: voucher.Name, + Email: voucher.Email, + PhoneNumber: voucher.PhoneNumber, + VoucherCode: voucher.VoucherCode, + WinnerNumber: voucher.WinnerNumber, + IsWinner: voucher.IsWinner, + } +} + +func VoucherModelToContract(voucher *models.VoucherResponse) *contract.VoucherResponse { + return &contract.VoucherResponse{ + ID: voucher.ID, + Name: voucher.Name, + Email: voucher.Email, + PhoneNumber: voucher.PhoneNumber, + VoucherCode: voucher.VoucherCode, + WinnerNumber: voucher.WinnerNumber, + IsWinner: voucher.IsWinner, + } +} + +func VoucherEntityToSpinResponse(voucher *entities.Voucher) *models.VoucherSpinResponse { + maskedPhone := maskPhoneNumber(&voucher.PhoneNumber) + return &models.VoucherSpinResponse{ + VoucherCode: voucher.VoucherCode, + Name: voucher.Name, + PhoneNumber: maskedPhone, + IsWinner: voucher.IsWinner, + } +} + +func VoucherSpinModelToContract(voucher *models.VoucherSpinResponse) *contract.VoucherSpinResponse { + return &contract.VoucherSpinResponse{ + VoucherCode: voucher.VoucherCode, + Name: voucher.Name, + PhoneNumber: voucher.PhoneNumber, + IsWinner: voucher.IsWinner, + } +} + +func ListVouchersForSpinRequestToModel(req *contract.ListVouchersForSpinRequest) *models.ListVouchersForSpinRequest { + return &models.ListVouchersForSpinRequest{ + Limit: req.Limit, + } +} + +func ListVouchersForSpinResponseToContract(resp *models.ListVouchersForSpinResponse) *contract.ListVouchersForSpinResponse { + vouchers := make([]contract.VoucherSpinResponse, len(resp.Vouchers)) + for i, voucher := range resp.Vouchers { + vouchers[i] = *VoucherSpinModelToContract(&voucher) + } + + return &contract.ListVouchersForSpinResponse{ + Vouchers: vouchers, + Count: resp.Count, + } +} + +func VoucherRowModelToContract(row *models.VoucherRow) *contract.VoucherRow { + vouchers := make([]contract.VoucherSpinResponse, len(row.Vouchers)) + for i, voucher := range row.Vouchers { + vouchers[i] = *VoucherSpinModelToContract(&voucher) + } + + return &contract.VoucherRow{ + RowNumber: row.RowNumber, + Vouchers: vouchers, + } +} + +func ListVouchersByRowsRequestToModel(req *contract.ListVouchersByRowsRequest) *models.ListVouchersByRowsRequest { + return &models.ListVouchersByRowsRequest{ + Rows: req.Rows, + WinnerNumber: req.WinnerNumber, + } +} + +func ListVouchersByRowsResponseToContract(resp *models.ListVouchersByRowsResponse) *contract.ListVouchersByRowsResponse { + rows := make([]contract.VoucherRow, len(resp.Rows)) + for i, row := range resp.Rows { + rows[i] = *VoucherRowModelToContract(&row) + } + + return &contract.ListVouchersByRowsResponse{ + Rows: rows, + TotalRows: resp.TotalRows, + TotalVouchers: resp.TotalVouchers, + } +} + +// maskPhoneNumber masks phone number for privacy +func maskPhoneNumber(phone *string) string { + if phone == nil || *phone == "" { + return "" + } + + phoneStr := *phone + if len(phoneStr) <= 4 { + return strings.Repeat("*", len(phoneStr)) + } + + // Show first 2 and last 2 characters, mask the middle + start := phoneStr[:2] + end := phoneStr[len(phoneStr)-2:] + middle := strings.Repeat("*", len(phoneStr)-4) + + return start + middle + end +} diff --git a/internal/models/voucher.go b/internal/models/voucher.go new file mode 100644 index 0000000..4b34f4e --- /dev/null +++ b/internal/models/voucher.go @@ -0,0 +1,51 @@ +package models + +type VoucherResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + PhoneNumber string `json:"phone_number"` + VoucherCode string `json:"voucher_code"` + WinnerNumber int `json:"winner_number"` + IsWinner bool `json:"is_winner"` +} + +type VoucherSpinResponse struct { + VoucherCode string `json:"voucher_code"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` // This will be masked + IsWinner bool `json:"is_winner"` +} + +type ListVouchersForSpinRequest struct { + Limit int `json:"limit" validate:"min=1,max=50"` +} + +type ListVouchersForSpinResponse struct { + Vouchers []VoucherSpinResponse `json:"vouchers"` + Count int `json:"count"` +} + +type PaginatedVoucherResponse struct { + Data []VoucherResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +type VoucherRow struct { + RowNumber int `json:"row_number"` + Vouchers []VoucherSpinResponse `json:"vouchers"` +} + +type ListVouchersByRowsRequest struct { + Rows int `json:"rows" validate:"min=1,max=10"` + WinnerNumber *int `json:"winner_number,omitempty"` +} + +type ListVouchersByRowsResponse struct { + Rows []VoucherRow `json:"rows"` + TotalRows int `json:"total_rows"` + TotalVouchers int `json:"total_vouchers"` +} diff --git a/internal/processor/voucher_processor.go b/internal/processor/voucher_processor.go new file mode 100644 index 0000000..e9651f3 --- /dev/null +++ b/internal/processor/voucher_processor.go @@ -0,0 +1,156 @@ +package processor + +import ( + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" + "context" + "fmt" + "math/rand" + "time" +) + +type VoucherProcessor struct { + voucherRepo *repository.VoucherRepository +} + +func NewVoucherProcessor(voucherRepo *repository.VoucherRepository) *VoucherProcessor { + return &VoucherProcessor{ + voucherRepo: voucherRepo, + } +} + +// GetRandomVouchersForSpin retrieves random vouchers for spin feature +func (p *VoucherProcessor) GetRandomVouchersForSpin(ctx context.Context, req *models.ListVouchersForSpinRequest) (*models.ListVouchersForSpinResponse, error) { + // Set default limit if not provided + limit := req.Limit + if limit <= 0 { + limit = 10 // Default limit + } + if limit > 50 { + limit = 50 // Max limit + } + + // Get random vouchers from repository + vouchers, err := p.voucherRepo.GetRandomVouchers(ctx, limit) + if err != nil { + return nil, fmt.Errorf("failed to get random vouchers: %w", err) + } + + // Convert to spin response format + voucherResponses := make([]models.VoucherSpinResponse, len(vouchers)) + for i, voucher := range vouchers { + voucherResponses[i] = *mappers.VoucherEntityToSpinResponse(&voucher) + } + + return &models.ListVouchersForSpinResponse{ + Vouchers: voucherResponses, + Count: len(voucherResponses), + }, nil +} + +// GetVoucherByID retrieves a voucher by ID +func (p *VoucherProcessor) GetVoucherByID(ctx context.Context, voucherID int64) (*models.VoucherResponse, error) { + voucher, err := p.voucherRepo.GetByID(ctx, voucherID) + if err != nil { + return nil, fmt.Errorf("voucher not found: %w", err) + } + + return mappers.VoucherEntityToResponse(voucher), nil +} + +// GetVoucherByCode retrieves a voucher by voucher code +func (p *VoucherProcessor) GetVoucherByCode(ctx context.Context, voucherCode string) (*models.VoucherResponse, error) { + voucher, err := p.voucherRepo.GetByVoucherCode(ctx, voucherCode) + if err != nil { + return nil, fmt.Errorf("voucher not found: %w", err) + } + + return mappers.VoucherEntityToResponse(voucher), nil +} + +// ListVouchers retrieves vouchers with pagination +func (p *VoucherProcessor) ListVouchers(ctx context.Context, page, limit int) (*models.PaginatedVoucherResponse, error) { + offset := (page - 1) * limit + + vouchers, total, err := p.voucherRepo.List(ctx, offset, limit) + if err != nil { + return nil, fmt.Errorf("failed to list vouchers: %w", err) + } + + // Convert to response format + voucherResponses := make([]models.VoucherResponse, len(vouchers)) + for i, voucher := range vouchers { + voucherResponses[i] = *mappers.VoucherEntityToResponse(&voucher) + } + + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + return &models.PaginatedVoucherResponse{ + Data: voucherResponses, + TotalCount: int(total), + Page: page, + Limit: limit, + TotalPages: totalPages, + }, nil +} + +// GetRandomVouchersByRows retrieves random vouchers organized into rows +func (p *VoucherProcessor) GetRandomVouchersByRows(ctx context.Context, req *models.ListVouchersByRowsRequest) (*models.ListVouchersByRowsResponse, error) { + // Set default values if not provided + rows := req.Rows + if rows <= 0 { + rows = 4 // Default to 4 rows + } + if rows > 10 { + rows = 10 // Max 10 rows + } + + // Get random vouchers organized by rows + voucherRows, err := p.voucherRepo.GetRandomVouchersByRows(ctx, rows, req.WinnerNumber) + if err != nil { + return nil, fmt.Errorf("failed to get random vouchers by rows: %w", err) + } + + // Convert to response format and select winners + responseRows := make([]models.VoucherRow, len(voucherRows)) + totalVouchers := 0 + + for i, row := range voucherRows { + vouchers := make([]models.VoucherSpinResponse, len(row)) + + // Select a random winner from this row if there are vouchers + if len(row) > 0 { + // Select random winner + rand.Seed(time.Now().UnixNano() + int64(i)) // Add row index for different seed + winnerIndex := rand.Intn(len(row)) + + // Mark as winner in database + err := p.voucherRepo.MarkAsWinner(ctx, row[winnerIndex].ID) + if err != nil { + // Log error but continue - don't fail the entire request + fmt.Printf("Failed to mark voucher %d as winner: %v\n", row[winnerIndex].ID, err) + } else { + // Update the voucher object to reflect the change + row[winnerIndex].IsWinner = true + } + } + + // Convert all vouchers to response format + for j, voucher := range row { + vouchers[j] = *mappers.VoucherEntityToSpinResponse(&voucher) + } + + responseRows[i] = models.VoucherRow{ + RowNumber: i + 1, // Row numbers start from 1 + Vouchers: vouchers, + } + totalVouchers += len(row) + } + + return &models.ListVouchersByRowsResponse{ + Rows: responseRows, + TotalRows: len(responseRows), + TotalVouchers: totalVouchers, + }, nil +} diff --git a/internal/repository/voucher_repository.go b/internal/repository/voucher_repository.go new file mode 100644 index 0000000..58d336e --- /dev/null +++ b/internal/repository/voucher_repository.go @@ -0,0 +1,228 @@ +package repository + +import ( + "apskel-pos-be/internal/entities" + "context" + "math/rand" + "time" + + "gorm.io/gorm" +) + +type VoucherRepository struct { + db *gorm.DB +} + +func NewVoucherRepository(db *gorm.DB) *VoucherRepository { + return &VoucherRepository{db: db} +} + +func (r *VoucherRepository) Create(ctx context.Context, voucher *entities.Voucher) error { + return r.db.WithContext(ctx).Create(voucher).Error +} + +func (r *VoucherRepository) GetByID(ctx context.Context, id int64) (*entities.Voucher, error) { + var voucher entities.Voucher + err := r.db.WithContext(ctx).Where("id = ?", id).First(&voucher).Error + if err != nil { + return nil, err + } + return &voucher, nil +} + +func (r *VoucherRepository) GetByVoucherCode(ctx context.Context, voucherCode string) (*entities.Voucher, error) { + var voucher entities.Voucher + err := r.db.WithContext(ctx).Where("voucher_code = ?", voucherCode).First(&voucher).Error + if err != nil { + return nil, err + } + return &voucher, nil +} + +func (r *VoucherRepository) List(ctx context.Context, offset, limit int) ([]entities.Voucher, int64, error) { + var vouchers []entities.Voucher + var total int64 + + query := r.db.WithContext(ctx) + + if err := query.Model(&entities.Voucher{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Offset(offset).Limit(limit).Find(&vouchers).Error + if err != nil { + return nil, 0, err + } + + return vouchers, total, nil +} + +func (r *VoucherRepository) GetRandomVouchers(ctx context.Context, limit int) ([]entities.Voucher, error) { + var vouchers []entities.Voucher + + // First, get the total count + var total int64 + if err := r.db.WithContext(ctx).Model(&entities.Voucher{}).Count(&total).Error; err != nil { + return nil, err + } + + if total == 0 { + return vouchers, nil + } + + // If we have fewer vouchers than requested, return all + if int(total) <= limit { + err := r.db.WithContext(ctx).Find(&vouchers).Error + return vouchers, err + } + + // Generate random offsets to get random vouchers + rand.Seed(time.Now().UnixNano()) + usedOffsets := make(map[int]bool) + + for len(vouchers) < limit && len(usedOffsets) < int(total) { + offset := rand.Intn(int(total)) + if usedOffsets[offset] { + continue + } + usedOffsets[offset] = true + + var voucher entities.Voucher + err := r.db.WithContext(ctx).Offset(offset).Limit(1).First(&voucher).Error + if err != nil { + continue + } + vouchers = append(vouchers, voucher) + } + + return vouchers, nil +} + +func (r *VoucherRepository) GetRandomVouchersByRows(ctx context.Context, rows int, winnerNumber *int) ([][]entities.Voucher, error) { + var allVouchers []entities.Voucher + var err error + + // First, try to get vouchers based on winner_number parameter + if winnerNumber != nil { + // If winner_number is provided, filter by it and exclude already won vouchers + query := r.db.WithContext(ctx).Where("winner_number = ? AND is_winner = ?", *winnerNumber, false) + err = query.Find(&allVouchers).Error + if err != nil { + return nil, err + } + + // If no vouchers found for the specified winner_number, fallback to winner_number = 0 + if len(allVouchers) == 0 { + fallbackQuery := r.db.WithContext(ctx).Where("winner_number = ? AND is_winner = ?", 0, false) + err = fallbackQuery.Find(&allVouchers).Error + if err != nil { + return nil, err + } + } + + // If still no vouchers found, try without is_winner filter for winner_number = 0 + if len(allVouchers) == 0 { + fallbackQuery2 := r.db.WithContext(ctx).Where("winner_number = ?", 0) + err = fallbackQuery2.Find(&allVouchers).Error + if err != nil { + return nil, err + } + } + + // If still no vouchers found, get any vouchers available + if len(allVouchers) == 0 { + err = r.db.WithContext(ctx).Find(&allVouchers).Error + if err != nil { + return nil, err + } + } + } else { + // If winner_number is not provided, use default winner_number = 0 and exclude already won vouchers + query := r.db.WithContext(ctx).Where("winner_number = ? AND is_winner = ?", 0, false) + err = query.Find(&allVouchers).Error + if err != nil { + return nil, err + } + + // If no vouchers found, try without is_winner filter for winner_number = 0 + if len(allVouchers) == 0 { + fallbackQuery := r.db.WithContext(ctx).Where("winner_number = ?", 0) + err = fallbackQuery.Find(&allVouchers).Error + if err != nil { + return nil, err + } + } + + // If still no vouchers found, get any vouchers available + if len(allVouchers) == 0 { + err = r.db.WithContext(ctx).Find(&allVouchers).Error + if err != nil { + return nil, err + } + } + } + + if len(allVouchers) == 0 { + // Return empty rows if no vouchers available + return make([][]entities.Voucher, rows), nil + } + + // Shuffle the vouchers for randomness + rand.Seed(time.Now().UnixNano()) + rand.Shuffle(len(allVouchers), func(i, j int) { + allVouchers[i], allVouchers[j] = allVouchers[j], allVouchers[i] + }) + + // Calculate vouchers per row (distribute evenly) + vouchersPerRow := len(allVouchers) / rows + if vouchersPerRow == 0 { + vouchersPerRow = 1 // At least 1 voucher per row + } + + // Organize vouchers into rows + voucherRows := make([][]entities.Voucher, rows) + voucherIndex := 0 + + for i := 0; i < rows; i++ { + // Calculate how many vouchers this row should have + remainingRows := rows - i + remainingVouchers := len(allVouchers) - voucherIndex + + // Distribute remaining vouchers evenly among remaining rows + rowSize := remainingVouchers / remainingRows + if remainingVouchers%remainingRows > 0 { + rowSize++ + } + + // Ensure we don't exceed available vouchers + if voucherIndex+rowSize > len(allVouchers) { + rowSize = len(allVouchers) - voucherIndex + } + + // Create the row + if voucherIndex < len(allVouchers) { + endIdx := voucherIndex + rowSize + if endIdx > len(allVouchers) { + endIdx = len(allVouchers) + } + voucherRows[i] = allVouchers[voucherIndex:endIdx] + voucherIndex = endIdx + } else { + voucherRows[i] = []entities.Voucher{} // Empty row + } + } + + return voucherRows, nil +} + +func (r *VoucherRepository) Update(ctx context.Context, voucher *entities.Voucher) error { + return r.db.WithContext(ctx).Save(voucher).Error +} + +func (r *VoucherRepository) Delete(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Delete(&entities.Voucher{}, id).Error +} + +func (r *VoucherRepository) MarkAsWinner(ctx context.Context, id int64) error { + return r.db.WithContext(ctx).Model(&entities.Voucher{}).Where("id = ?", id).Update("is_winner", true).Error +} diff --git a/internal/router/router.go b/internal/router/router.go index 17a67c1..67d6b79 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -40,6 +40,7 @@ type Router struct { chartOfAccountHandler *handler.ChartOfAccountHandler accountHandler *handler.AccountHandler orderIngredientTransactionHandler *handler.OrderIngredientTransactionHandler + voucherHandler *handler.VoucherHandler authMiddleware *middleware.AuthMiddleware } @@ -90,7 +91,9 @@ func NewRouter(cfg *config.Config, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, - orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator) *Router { + orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, + voucherService service.VoucherService, + voucherValidator validator.VoucherValidator) *Router { return &Router{ config: cfg, @@ -120,6 +123,7 @@ func NewRouter(cfg *config.Config, chartOfAccountHandler: handler.NewChartOfAccountHandler(chartOfAccountService, chartOfAccountValidator), accountHandler: handler.NewAccountHandler(accountService, accountValidator), orderIngredientTransactionHandler: handler.NewOrderIngredientTransactionHandler(&orderIngredientTransactionService, orderIngredientTransactionValidator), + voucherHandler: handler.NewVoucherHandler(voucherService, voucherValidator), authMiddleware: authMiddleware, } } @@ -426,6 +430,16 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { orderIngredientTransactions.POST("/bulk", r.orderIngredientTransactionHandler.BulkCreateOrderIngredientTransactions) } + vouchers := protected.Group("/vouchers") + vouchers.Use(r.authMiddleware.RequireAdminOrManager()) + { + vouchers.GET("/spin", r.voucherHandler.GetRandomVouchersForSpin) + vouchers.GET("/rows", r.voucherHandler.GetRandomVouchersByRows) + vouchers.GET("", r.voucherHandler.ListVouchers) + vouchers.GET("/:id", r.voucherHandler.GetVoucherByID) + vouchers.GET("/code/:code", r.voucherHandler.GetVoucherByCode) + } + outlets := protected.Group("/outlets") outlets.Use(r.authMiddleware.RequireAdminOrManager()) { diff --git a/internal/service/voucher_service.go b/internal/service/voucher_service.go new file mode 100644 index 0000000..a70643b --- /dev/null +++ b/internal/service/voucher_service.go @@ -0,0 +1,93 @@ +package service + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/processor" + "context" +) + +type VoucherService interface { + GetRandomVouchersForSpin(ctx context.Context, req *contract.ListVouchersForSpinRequest) (*contract.ListVouchersForSpinResponse, error) + GetRandomVouchersByRows(ctx context.Context, req *contract.ListVouchersByRowsRequest) (*contract.ListVouchersByRowsResponse, error) + GetVoucherByID(ctx context.Context, voucherID int64) (*contract.VoucherResponse, error) + GetVoucherByCode(ctx context.Context, voucherCode string) (*contract.VoucherResponse, error) + ListVouchers(ctx context.Context, page, limit int) (*contract.PaginatedVoucherResponse, error) +} + +type VoucherServiceImpl struct { + voucherProcessor *processor.VoucherProcessor +} + +func NewVoucherService(voucherProcessor *processor.VoucherProcessor) *VoucherServiceImpl { + return &VoucherServiceImpl{ + voucherProcessor: voucherProcessor, + } +} + +func (s *VoucherServiceImpl) GetRandomVouchersForSpin(ctx context.Context, req *contract.ListVouchersForSpinRequest) (*contract.ListVouchersForSpinResponse, error) { + modelReq := mappers.ListVouchersForSpinRequestToModel(req) + + response, err := s.voucherProcessor.GetRandomVouchersForSpin(ctx, modelReq) + if err != nil { + return nil, err + } + + contractResponse := mappers.ListVouchersForSpinResponseToContract(response) + return contractResponse, nil +} + +func (s *VoucherServiceImpl) GetRandomVouchersByRows(ctx context.Context, req *contract.ListVouchersByRowsRequest) (*contract.ListVouchersByRowsResponse, error) { + modelReq := mappers.ListVouchersByRowsRequestToModel(req) + + response, err := s.voucherProcessor.GetRandomVouchersByRows(ctx, modelReq) + if err != nil { + return nil, err + } + + contractResponse := mappers.ListVouchersByRowsResponseToContract(response) + return contractResponse, nil +} + +func (s *VoucherServiceImpl) GetVoucherByID(ctx context.Context, voucherID int64) (*contract.VoucherResponse, error) { + response, err := s.voucherProcessor.GetVoucherByID(ctx, voucherID) + if err != nil { + return nil, err + } + + contractResponse := mappers.VoucherModelToContract(response) + return contractResponse, nil +} + +func (s *VoucherServiceImpl) GetVoucherByCode(ctx context.Context, voucherCode string) (*contract.VoucherResponse, error) { + response, err := s.voucherProcessor.GetVoucherByCode(ctx, voucherCode) + if err != nil { + return nil, err + } + + contractResponse := mappers.VoucherModelToContract(response) + return contractResponse, nil +} + +func (s *VoucherServiceImpl) ListVouchers(ctx context.Context, page, limit int) (*contract.PaginatedVoucherResponse, error) { + response, err := s.voucherProcessor.ListVouchers(ctx, page, limit) + if err != nil { + return nil, err + } + + // Convert to contract response + vouchers := make([]contract.VoucherResponse, len(response.Data)) + for i, voucher := range response.Data { + vouchers[i] = *mappers.VoucherModelToContract(&voucher) + } + + contractResponse := &contract.PaginatedVoucherResponse{ + Data: vouchers, + TotalCount: response.TotalCount, + Page: response.Page, + Limit: response.Limit, + TotalPages: response.TotalPages, + } + + return contractResponse, nil +} diff --git a/internal/validator/voucher_validator.go b/internal/validator/voucher_validator.go new file mode 100644 index 0000000..be3509f --- /dev/null +++ b/internal/validator/voucher_validator.go @@ -0,0 +1,41 @@ +package validator + +import ( + "apskel-pos-be/internal/contract" + "errors" +) + +type VoucherValidator interface { + ValidateListVouchersForSpinRequest(req *contract.ListVouchersForSpinRequest) (error, string) + ValidateListVouchersByRowsRequest(req *contract.ListVouchersByRowsRequest) (error, string) +} + +type VoucherValidatorImpl struct{} + +func NewVoucherValidator() VoucherValidator { + return &VoucherValidatorImpl{} +} + +func (v *VoucherValidatorImpl) ValidateListVouchersForSpinRequest(req *contract.ListVouchersForSpinRequest) (error, string) { + if req.Limit < 1 { + return errors.New("limit must be at least 1"), "INVALID_LIMIT" + } + + if req.Limit > 50 { + return errors.New("limit cannot exceed 50"), "INVALID_LIMIT" + } + + return nil, "" +} + +func (v *VoucherValidatorImpl) ValidateListVouchersByRowsRequest(req *contract.ListVouchersByRowsRequest) (error, string) { + if req.Rows < 1 { + return errors.New("rows must be at least 1"), "INVALID_ROWS" + } + + if req.Rows > 10 { + return errors.New("rows cannot exceed 10"), "INVALID_ROWS" + } + + return nil, "" +} diff --git a/migrations/001_create_vouchers_table.sql b/migrations/001_create_vouchers_table.sql new file mode 100644 index 0000000..59961b5 --- /dev/null +++ b/migrations/001_create_vouchers_table.sql @@ -0,0 +1,17 @@ +-- Create vouchers table +CREATE TABLE IF NOT EXISTS vouchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + phone_number VARCHAR(20), + voucher_code VARCHAR(50) NOT NULL UNIQUE, + winner_number INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index on voucher_code for faster lookups +CREATE INDEX IF NOT EXISTS idx_vouchers_voucher_code ON vouchers(voucher_code); + +-- Create index on winner_number for sorting +CREATE INDEX IF NOT EXISTS idx_vouchers_winner_number ON vouchers(winner_number); diff --git a/server b/server index 3ed1e65..0553262 100755 Binary files a/server and b/server differ diff --git a/test_voucher_api.md b/test_voucher_api.md new file mode 100644 index 0000000..8980a1b --- /dev/null +++ b/test_voucher_api.md @@ -0,0 +1,222 @@ +# Voucher API Test Guide + +## API Endpoints + +### 1. Get Random Vouchers for Spin +**GET** `/api/v1/vouchers/spin` + +Query Parameters: +- `limit` (optional): Number of vouchers to return (1-50, default: 10) + +**Response:** +```json +{ + "message": "Success", + "data": { + "vouchers": [ + { + "voucher_code": "VOUCHER001", + "name": "John Doe", + "phone_number": "08**1234", + "is_winner": false + }, + { + "voucher_code": "VOUCHER002", + "name": "Jane Smith", + "phone_number": "09**5678", + "is_winner": false + } + ], + "count": 2 + } +} +``` + +### 2. Get Random Vouchers by Rows (UPDATED!) +**GET** `/api/v1/vouchers/rows` + +Query Parameters: +- `rows` (optional): Number of rows to return (1-10, default: 4) +- `winner_number` (optional): Winner number to filter by (if not provided, defaults to 0) + +**Logic:** +- If `winner_number` is provided: Randomly select from vouchers where `winner_number = provided_value` AND `is_winner = false` +- If no vouchers found for the specified `winner_number`: Fallback to `winner_number = 0` AND `is_winner = false` +- If `winner_number` is not provided: Randomly select from vouchers where `winner_number = 0` AND `is_winner = false` +- Excludes vouchers already marked as winners +- Distributes vouchers evenly across the specified number of rows +- **Automatically selects one random winner from each row and sets their `is_winner` to `true`** + +**Response:** +```json +{ + "message": "Success", + "data": { + "rows": [ + { + "row_number": 1, + "vouchers": [ + { + "voucher_code": "VOUCHER001", + "name": "John Doe", + "phone_number": "08**1234", + "is_winner": true + }, + { + "voucher_code": "VOUCHER002", + "name": "Jane Smith", + "phone_number": "09**5678", + "is_winner": false + }, + { + "voucher_code": "VOUCHER003", + "name": "Bob Johnson", + "phone_number": "08**9012", + "is_winner": false + } + ] + }, + { + "row_number": 2, + "vouchers": [ + { + "voucher_code": "VOUCHER004", + "name": "Alice Brown", + "phone_number": "09**3456", + "is_winner": false + }, + { + "voucher_code": "VOUCHER005", + "name": "Charlie Wilson", + "phone_number": "08**7890", + "is_winner": true + } + ] + } + ], + "total_rows": 2, + "total_vouchers": 5 + } +} +``` + +### 3. List All Vouchers +**GET** `/api/v1/vouchers` + +Query Parameters: +- `page` (optional): Page number (default: 1) +- `limit` (optional): Items per page (default: 10, max: 100) + +**Response:** +```json +{ + "message": "Success", + "data": { + "data": [ + { + "id": "uuid", + "name": "John Doe", + "email": "john@example.com", + "phone_number": "08123456789", + "voucher_code": "VOUCHER001", + "winner_number": 1, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ], + "total_count": 1, + "page": 1, + "limit": 10, + "total_pages": 1 + } +} +``` + +### 4. Get Voucher by ID +**GET** `/api/v1/vouchers/{id}` + +**Response:** +```json +{ + "message": "Success", + "data": { + "id": "uuid", + "name": "John Doe", + "email": "john@example.com", + "phone_number": "08123456789", + "voucher_code": "VOUCHER001", + "winner_number": 1, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } +} +``` + +### 5. Get Voucher by Code +**GET** `/api/v1/vouchers/code/{code}` + +**Response:** +```json +{ + "message": "Success", + "data": { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "phone_number": "08123456789", + "voucher_code": "VOUCHER001", + "winner_number": 1, + "is_winner": false + } +} +``` + +## Features + +1. **Random Selection**: The `/spin` endpoint returns random vouchers from the database +2. **Phone Number Masking**: Phone numbers are masked for privacy (shows first 2 and last 2 digits) +3. **Pagination**: List endpoint supports pagination +4. **Authentication**: All endpoints require admin or manager role +5. **Validation**: Input validation for limit parameters + +## Database Schema + +```sql +CREATE TABLE vouchers ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL, + phone_number VARCHAR(20) NOT NULL, + voucher_code VARCHAR(50) NOT NULL, + winner_number INT NOT NULL DEFAULT 0, + is_winner BOOLEAN NOT NULL DEFAULT FALSE +); +``` + +## Usage Examples + +```bash +# Get 5 random vouchers for spin +curl -X GET "http://localhost:8080/api/v1/vouchers/spin?limit=5" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Get 4 rows (default) - vouchers distributed evenly across rows +curl -X GET "http://localhost:8080/api/v1/vouchers/rows" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Get 3 rows - vouchers distributed evenly across 3 rows (from winner_number = 0) +curl -X GET "http://localhost:8080/api/v1/vouchers/rows?rows=3" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Get 3 rows - vouchers from winner_number = 1 (fallback to winner_number = 0 if none found) +curl -X GET "http://localhost:8080/api/v1/vouchers/rows?rows=3&winner_number=1" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# Get 3 rows - vouchers from winner_number = 5 (will fallback to winner_number = 0 if none found) +curl -X GET "http://localhost:8080/api/v1/vouchers/rows?rows=3&winner_number=5" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" + +# List all vouchers with pagination +curl -X GET "http://localhost:8080/api/v1/vouchers?page=1&limit=20" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +```