package service import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "github.com/google/uuid" "apskel-pos-be/internal/appcontext" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/logger" "apskel-pos-be/internal/models" "apskel-pos-be/internal/processor" "apskel-pos-be/internal/repository" ) // EnakGameAdminService is the dashboard side of EnakGame: games, reward // configurations and budgets of the admin's organization (docs/rfc-enakgame.md ยง11). // Bodies are read strictly, so a misspelt field is refused instead of ignored. type EnakGameAdminService interface { CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response // UpdateGame changes the fields the body sends and keeps the others. UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response // UpdateBudget changes the fields the body sends and keeps the others. UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response // UpdateEvent changes the fields the body sends and keeps the others. UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response // UpdateVoucher changes the fields the body sends and keeps the others. UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response // ImportVoucherCodes adds the codes of a CSV to a CODE_POOL voucher. ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response } // EnakGameCustomerService is what the customer app does with EnakGame. type EnakGameCustomerService interface { ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response } type EnakGameAdminServiceImpl struct { games *processor.EnakGameAdminProcessor budgets *processor.GameBudgetProcessor metrics *processor.GameBudgetMetricsProcessor events *processor.GameEventProcessor vouchers *processor.VoucherAdminProcessor } func NewEnakGameAdminService(games *processor.EnakGameAdminProcessor, budgets *processor.GameBudgetProcessor, metrics *processor.GameBudgetMetricsProcessor, events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor) *EnakGameAdminServiceImpl { return &EnakGameAdminServiceImpl{games: games, budgets: budgets, metrics: metrics, events: events, vouchers: vouchers} } type EnakGameCustomerServiceImpl struct { sessions *processor.GameSessionProcessor redemptions *processor.VoucherRedemptionProcessor } func NewEnakGameCustomerService(sessions *processor.GameSessionProcessor, redemptions *processor.VoucherRedemptionProcessor) *EnakGameCustomerServiceImpl { return &EnakGameCustomerServiceImpl{sessions: sessions, redemptions: redemptions} } // respond wraps a processor result, or its error, as a response. func respond[T any](ctx context.Context, result T, err error) *contract.Response { if err != nil { return enakGameErrorResponse(ctx, err) } return contract.BuildSuccessResponse(result) } func (s *EnakGameAdminServiceImpl) CreateGame(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { var in models.EnakGameInput if resp := decodeStrict(body, &in); resp != nil { return resp } game, err := s.games.CreateGame(ctx, apctx.OrganizationID, apctx.UserID, in) return respond(ctx, game, err) } func (s *EnakGameAdminServiceImpl) ListGames(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameListQuery) *contract.Response { games, err := s.games.ListGames(ctx, apctx.OrganizationID, q) return respond(ctx, games, err) } func (s *EnakGameAdminServiceImpl) GetGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { game, err := s.games.GetGame(ctx, apctx.OrganizationID, id) return respond(ctx, game, err) } func (s *EnakGameAdminServiceImpl) UpdateGame(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { current, err := s.games.GetGame(ctx, apctx.OrganizationID, id) if err != nil { return enakGameErrorResponse(ctx, err) } in := processor.EnakGameInputFrom(current) if resp := decodeStrict(body, &in); resp != nil { return resp } game, err := s.games.UpdateGame(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, game, err) } func (s *EnakGameAdminServiceImpl) SetGameStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { var in models.EnakGameStatusInput if resp := decodeStrict(body, &in); resp != nil { return resp } game, err := s.games.SetGameStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, game, err) } func (s *EnakGameAdminServiceImpl) CreateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID, body []byte) *contract.Response { var in models.GameRewardConfigInput if resp := decodeStrict(body, &in); resp != nil { return resp } config, err := s.games.CreateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, gameID, in) return respond(ctx, config, err) } func (s *EnakGameAdminServiceImpl) ListRewardConfigs(ctx context.Context, apctx *appcontext.ContextInfo, gameID uuid.UUID) *contract.Response { configs, err := s.games.ListRewardConfigs(ctx, apctx.OrganizationID, gameID) return respond(ctx, configs, err) } func (s *EnakGameAdminServiceImpl) ActivateRewardConfig(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { var in models.GameRewardConfigActivateInput if len(bytes.TrimSpace(body)) > 0 { if resp := decodeStrict(body, &in); resp != nil { return resp } } config, err := s.games.ActivateRewardConfig(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, config, err) } func (s *EnakGameAdminServiceImpl) CreateBudget(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { var in models.GameBudgetInput if resp := decodeStrict(body, &in); resp != nil { return resp } budget, err := s.budgets.CreateBudget(ctx, apctx.OrganizationID, apctx.UserID, in) return respond(ctx, budget, err) } func (s *EnakGameAdminServiceImpl) ListBudgets(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameBudgetListQuery) *contract.Response { budgets, err := s.budgets.ListBudgets(ctx, apctx.OrganizationID, q) return respond(ctx, budgets, err) } func (s *EnakGameAdminServiceImpl) GetBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { budget, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id) return respond(ctx, budget, err) } func (s *EnakGameAdminServiceImpl) UpdateBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { current, err := s.budgets.GetBudget(ctx, apctx.OrganizationID, id) if err != nil { return enakGameErrorResponse(ctx, err) } in := processor.GameBudgetInputFrom(current) if resp := decodeStrict(body, &in); resp != nil { return resp } budget, err := s.budgets.UpdateBudget(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, budget, err) } func (s *EnakGameAdminServiceImpl) DeleteBudget(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { if err := s.budgets.DeleteBudget(ctx, apctx.OrganizationID, apctx.UserID, id); err != nil { return enakGameErrorResponse(ctx, err) } return contract.BuildSuccessResponse(map[string]any{"id": id, "deleted": true}) } func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { metrics, err := s.metrics.Metrics(ctx, apctx.OrganizationID, id) return respond(ctx, metrics, err) } func (s *EnakGameAdminServiceImpl) CreateEvent(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { var in models.GameEventInput if resp := decodeStrict(body, &in); resp != nil { return resp } event, err := s.events.CreateEvent(ctx, apctx.OrganizationID, apctx.UserID, in) return respond(ctx, event, err) } func (s *EnakGameAdminServiceImpl) ListEvents(ctx context.Context, apctx *appcontext.ContextInfo, q models.GameEventListQuery) *contract.Response { events, err := s.events.ListEvents(ctx, apctx.OrganizationID, q) return respond(ctx, events, err) } func (s *EnakGameAdminServiceImpl) GetEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { event, err := s.events.GetEvent(ctx, apctx.OrganizationID, id) return respond(ctx, event, err) } func (s *EnakGameAdminServiceImpl) UpdateEvent(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { current, err := s.events.GetEvent(ctx, apctx.OrganizationID, id) if err != nil { return enakGameErrorResponse(ctx, err) } in := processor.GameEventInputFrom(current) if resp := decodeStrict(body, &in); resp != nil { return resp } event, err := s.events.UpdateEvent(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, event, err) } func (s *EnakGameAdminServiceImpl) SetEventStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { var in models.GameEventStatusInput if resp := decodeStrict(body, &in); resp != nil { return resp } event, err := s.events.SetEventStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, event, err) } func (s *EnakGameAdminServiceImpl) CreateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, body []byte) *contract.Response { var in models.VoucherInput if resp := decodeStrict(body, &in); resp != nil { return resp } voucher, err := s.vouchers.CreateVoucher(ctx, apctx.OrganizationID, apctx.UserID, in) return respond(ctx, voucher, err) } func (s *EnakGameAdminServiceImpl) ListVouchers(ctx context.Context, apctx *appcontext.ContextInfo, q models.VoucherListQuery) *contract.Response { vouchers, err := s.vouchers.ListVouchers(ctx, apctx.OrganizationID, q) return respond(ctx, vouchers, err) } func (s *EnakGameAdminServiceImpl) GetVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { voucher, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id) return respond(ctx, voucher, err) } func (s *EnakGameAdminServiceImpl) UpdateVoucher(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { current, err := s.vouchers.GetVoucher(ctx, apctx.OrganizationID, id) if err != nil { return enakGameErrorResponse(ctx, err) } in := processor.VoucherInputFrom(current) if resp := decodeStrict(body, &in); resp != nil { return resp } voucher, err := s.vouchers.UpdateVoucher(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, voucher, err) } func (s *EnakGameAdminServiceImpl) SetVoucherStatus(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { var in models.VoucherStatusInput if resp := decodeStrict(body, &in); resp != nil { return resp } voucher, err := s.vouchers.SetVoucherStatus(ctx, apctx.OrganizationID, apctx.UserID, id, in) return respond(ctx, voucher, err) } func (s *EnakGameAdminServiceImpl) ImportVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, csv []byte) *contract.Response { result, err := s.vouchers.ImportCodes(ctx, apctx.OrganizationID, apctx.UserID, id, csv) return respond(ctx, result, err) } func (s *EnakGameAdminServiceImpl) ListVoucherCodes(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, q models.VoucherCodeListQuery) *contract.Response { codes, err := s.vouchers.Codes(ctx, apctx.OrganizationID, id, q) return respond(ctx, codes, err) } func (s *EnakGameCustomerServiceImpl) ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response { vouchers, err := s.redemptions.Catalog(ctx, customerID) return respond(ctx, vouchers, err) } func (s *EnakGameCustomerServiceImpl) RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response { result, err := s.redemptions.Redeem(ctx, customerID, voucherID, req.Pin, idempotencyKey, info) return respond(ctx, result, err) } func (s *EnakGameCustomerServiceImpl) ListRedemptions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { redemptions, err := s.redemptions.ListRedemptions(ctx, customerID, page, limit) return respond(ctx, redemptions, err) } func (s *EnakGameCustomerServiceImpl) ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response { games, err := s.sessions.ListGames(ctx, customerID) return respond(ctx, games, err) } func (s *EnakGameCustomerServiceImpl) StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response { started, err := s.sessions.Start(ctx, customerID, req.GameID, idempotencyKey) return respond(ctx, started, err) } func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response { completion, err := s.sessions.Complete(ctx, customerID, sessionID, in) return respond(ctx, completion, err) } func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { sessions, err := s.sessions.ListSessions(ctx, customerID, page, limit) return respond(ctx, sessions, err) } func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response { session, err := s.sessions.GetSession(ctx, customerID, sessionID) return respond(ctx, session, err) } // decodeStrict reads a JSON body into v, refusing unknown fields and trailing data. // It returns the error response, or nil when the body fits. func decodeStrict(body []byte, v any) *contract.Response { dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() err := dec.Decode(v) if err == nil { if _, trailing := dec.Token(); trailing != io.EOF { err = errors.New("unexpected data after the JSON object") } } if err != nil { return contract.BuildErrorResponse([]*contract.ResponseError{ contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, fmt.Sprintf("invalid request body: %v", err)), }) } return nil } // enakGameErrorResponse tells a refused or missing thing apart from a server failure, // whose details stay in the log. func enakGameErrorResponse(ctx context.Context, err error) *contract.Response { var pinErr *processor.PinError if errors.As(err, &pinErr) { return PinErrorResponse(err) } code, cause := constants.InternalServerErrorCode, "internal error" switch { case errors.Is(err, repository.ErrEnakGameNotFound), errors.Is(err, repository.ErrGameRewardConfigNotFound), errors.Is(err, repository.ErrGameBudgetNotFound), errors.Is(err, repository.ErrGameSessionNotFound), errors.Is(err, repository.ErrVoucherNotFound), errors.Is(err, repository.ErrGameEventNotFound), errors.Is(err, repository.ErrWalletNotFound): code, cause = constants.NotFoundErrorCode, err.Error() case errors.Is(err, processor.ErrEnakGameRejected), errors.Is(err, processor.ErrGameSessionRejected), errors.Is(err, processor.ErrVoucherRedemptionRejected), errors.Is(err, processor.ErrWalletIdempotencyConflict): code, cause = constants.ValidationErrorCode, err.Error() default: logger.FromContext(ctx).WithError(err).Error("EnakGame request failed") } return contract.BuildErrorResponse([]*contract.ResponseError{ contract.NewResponseError(code, constants.EnakGameServiceEntity, cause), }) }