Main #41

Merged
aefril merged 16 commits from main into staging 2026-10-08 04:32:27 +02:00
28 changed files with 200 additions and 296 deletions
Showing only changes of commit e94eb2c26d - Show all commits
+1 -1
View File
@@ -15,6 +15,6 @@ COPY --from=build /out/app /app
ENV TZ=Asia/Jakarta ENV TZ=Asia/Jakarta
EXPOSE 4000 EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -fsS http://localhost:3300/health || exit 1 CMD curl -fsS http://localhost:4000/health || exit 1
USER appuser USER appuser
ENTRYPOINT ["/app"] ENTRYPOINT ["/app"]
+142 -27
View File
@@ -1,62 +1,177 @@
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
# Usage:
# ./deployment.sh staging deploy staging (branch staging, port 4001)
# ./deployment.sh production deploy production (branch main, port 4000)
# ./deployment.sh production --rollback kembali ke image sebelum deploy terakhir
# Tambahkan --yes untuk melewati konfirmasi production.
APP_NAME="apskel-pos" APP_NAME="apskel-pos"
LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment
# ─── Deteksi environment dari branch aktif ─────────────────────────────────── usage() {
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) echo "Usage: $0 <staging|production> [--rollback] [--yes]"
exit 1
}
case "$CURRENT_BRANCH" in fail() {
main) echo "❌ $*"
ENV_MODE="production" exit 1
}
# ─── Argumen ──────────────────────────────────────────────────────────────────
ENV_MODE="${1:-}"
shift || true
ROLLBACK=false
ASSUME_YES=false
for arg in "$@"; do
case "$arg" in
--rollback) ROLLBACK=true ;;
--yes|-y) ASSUME_YES=true ;;
*) usage ;;
esac
done
# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif
case "$ENV_MODE" in
production)
EXPECTED_BRANCH="main"
PORT="4000" PORT="4000"
;; ;;
staging) staging)
ENV_MODE="staging" EXPECTED_BRANCH="staging"
PORT="4001" PORT="4001"
;; ;;
*) *)
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment." usage
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
exit 1
;; ;;
esac esac
CONTAINER_NAME="$APP_NAME" CONTAINER_NAME="$APP_NAME-$ENV_MODE"
IMAGE_NAME="$APP_NAME:$ENV_MODE" IMAGE_NAME="$APP_NAME:$ENV_MODE"
PREVIOUS_IMAGE="$APP_NAME:$ENV_MODE-previous"
CONFIG_FILE="infra/$ENV_MODE.yaml"
# ─── Validasi repo ────────────────────────────────────────────────────────────
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
fail "Repo sedang detached HEAD. Jalankan 'git checkout $EXPECTED_BRANCH' dulu."
fi
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
fail "Deploy $ENV_MODE harus dari branch '$EXPECTED_BRANCH', folder ini di branch '$CURRENT_BRANCH'.
Pakai folder terpisah per environment, jangan pindah branch di folder yang sedang dipakai container lain."
fi
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
fail "Ada perubahan yang belum di-commit di folder ini. Bersihkan dulu sebelum deploy."
fi
[ -f "$CONFIG_FILE" ] || fail "Config file '$CONFIG_FILE' tidak ditemukan."
# ─── Pastikan port tidak dipakai container environment lain ──────────────────
OTHER_ON_PORT=$(docker ps --filter "publish=$PORT" --format '{{.Names}}' \
| grep -vx "$CONTAINER_NAME" \
| { if [ "$ENV_MODE" = "production" ]; then grep -vx "$LEGACY_CONTAINER"; else cat; fi; } \
|| true)
if [ -n "$OTHER_ON_PORT" ]; then
fail "Port $PORT sedang dipakai container lain: $OTHER_ON_PORT"
fi
echo "📦 Environment : $ENV_MODE" echo "📦 Environment : $ENV_MODE"
echo "🌿 Branch : $CURRENT_BRANCH" echo "🌿 Branch : $CURRENT_BRANCH"
echo "🐳 Container : $CONTAINER_NAME" echo "🐳 Container : $CONTAINER_NAME"
echo "🔌 Port : $PORT" echo "🔌 Port : $PORT"
echo "📁 Folder : $(pwd)"
[ "$ROLLBACK" = true ] && echo "⏪ Mode : ROLLBACK ke $PREVIOUS_IMAGE"
echo "" echo ""
# ─── Pastikan config file ada ───────────────────────────────────────────────── # ─── Konfirmasi production ────────────────────────────────────────────────────
CONFIG_FILE="infra/$ENV_MODE.yaml" if [ "$ENV_MODE" = "production" ] && [ "$ASSUME_YES" != true ]; then
if [ ! -f "$CONFIG_FILE" ]; then read -r -p "⚠️ Ini PRODUCTION. Ketik 'production' untuk lanjut: " CONFIRM
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan." [ "$CONFIRM" = "production" ] || fail "Dibatalkan."
exit 1
fi fi
echo "🔄 Pulling latest code..." run_container() {
git pull local image="$1"
echo "🛑 Stopping and removing old container..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# Container production lama masih bernama 'apskel-pos'; staging tidak boleh menyentuhnya
if [ "$ENV_MODE" = "production" ]; then
docker rm -f "$LEGACY_CONTAINER" 2>/dev/null || true
fi
echo "🚀 Running container from $image..."
docker run -d --name "$CONTAINER_NAME" \
--restart unless-stopped \
-p "$PORT:4000" \
-e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \
"$image" >/dev/null
}
wait_healthy() {
echo "🩺 Waiting for healthcheck..."
for _ in $(seq 1 36); do
local status
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
case "$status" in
healthy) return 0 ;;
unhealthy|exited|dead|missing) return 1 ;;
esac
sleep 5
done
return 1
}
# ─── Rollback ─────────────────────────────────────────────────────────────────
if [ "$ROLLBACK" = true ]; then
docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1 || fail "Image '$PREVIOUS_IMAGE' tidak ada, tidak bisa rollback."
run_container "$PREVIOUS_IMAGE"
wait_healthy || fail "Container hasil rollback tidak healthy. Cek: docker logs $CONTAINER_NAME"
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
echo "✅ Rollback $ENV_MODE complete."
exit 0
fi
# ─── Deploy ───────────────────────────────────────────────────────────────────
echo "🔄 Pulling latest code (origin/$EXPECTED_BRANCH)..."
git fetch origin "$EXPECTED_BRANCH"
git merge --ff-only "origin/$EXPECTED_BRANCH"
echo " Commit: $(git log -1 --format='%h %s')"
# Simpan image yang sedang jalan untuk rollback
if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
docker tag "$IMAGE_NAME" "$PREVIOUS_IMAGE"
fi
echo "🐳 Building Docker image ($ENV_MODE)..." echo "🐳 Building Docker image ($ENV_MODE)..."
docker build --target production -t "$IMAGE_NAME" . docker build --target production -t "$IMAGE_NAME" .
echo "🛑 Stopping and removing old container..." run_container "$IMAGE_NAME"
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
echo "🚀 Running new container..." if ! wait_healthy; then
docker run -d --name "$CONTAINER_NAME" \ echo "❌ Container baru tidak healthy. Log terakhir:"
-p "$PORT:4000" \ docker logs --tail 50 "$CONTAINER_NAME" || true
-e TZ=Asia/Jakarta \ if docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then
-e ENV_MODE="$ENV_MODE" \ echo "⏪ Rolling back ke $PREVIOUS_IMAGE..."
-v "$(pwd)/infra":/infra:ro \ run_container "$PREVIOUS_IMAGE"
-v "$(pwd)/templates":/templates:ro \ wait_healthy || fail "Rollback juga tidak healthy. Cek manual: docker logs $CONTAINER_NAME"
"$IMAGE_NAME" # Jangan biarkan image rusak jadi 'previous' di deploy berikutnya
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
fail "Deploy gagal, $ENV_MODE sudah dikembalikan ke image sebelumnya."
fi
fail "Deploy gagal dan tidak ada image sebelumnya untuk rollback."
fi
echo "" echo ""
echo "✅ Deployment $ENV_MODE complete." echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME" echo " Container : $CONTAINER_NAME"
echo " Port : $PORT" echo " Port : $PORT"
echo " Rollback : $0 $ENV_MODE --rollback"
+2 -5
View File
@@ -426,14 +426,11 @@ Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lew
### Endpoint dan field deprecated ### Endpoint dan field deprecated
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
| Lama | Pengganti | | Lama | Pengganti |
| --- | --- | | --- | --- |
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | | `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
+9 -10
View File
@@ -474,16 +474,14 @@ Response:
```json ```json
{ {
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" }, "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb", … }, "prize_won": { "id": "…", "name": "Voucher 10rb", … },
"coins_remaining": 7, "coins_remaining": 7
"tokens_remaining": 7
} }
``` ```
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan EnakCoin yang terpotong.
`tokens_remaining` hanya salinan untuk versi aplikasi lama.
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
@@ -497,13 +495,14 @@ pindah. Aplikasi baru jangan memakainya.
| Lama | Ganti dengan | | Lama | Ganti dengan |
|---|---| |---|---|
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | | `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` |
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus. supaya alias ini bisa dihapus.
Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
--- ---
-1
View File
@@ -52,7 +52,6 @@ const (
TableEntity = "table" TableEntity = "table"
// Gamification entities // Gamification entities
CustomerPointsEntity = "customer_points" CustomerPointsEntity = "customer_points"
CustomerTokensEntity = "customer_tokens"
TierEntity = "tier" TierEntity = "tier"
GameEntity = "game" GameEntity = "game"
GamePrizeEntity = "game_prize" GamePrizeEntity = "game_prize"
+5 -5
View File
@@ -12,7 +12,7 @@ import (
type CreateCampaignRequest struct { type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"` ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct { type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -25,26 +25,6 @@ type PointsHistoryItem struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type GetCustomerTokensResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
}
type GetCustomerTokensResponseData struct {
TotalTokens int64 `json:"total_tokens"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
type TokensHistoryItem struct {
ID string `json:"id"`
Tokens int64 `json:"tokens"`
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
type GetCustomerWalletResponse struct { type GetCustomerWalletResponse struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
@@ -53,8 +33,6 @@ type GetCustomerWalletResponse struct {
type GetCustomerWalletResponseData struct { type GetCustomerWalletResponseData struct {
TotalPoints int64 `json:"total_points"` TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
} }
+6 -10
View File
@@ -14,13 +14,11 @@ type CreateGamePlayRequest struct {
} }
type GamePlayResponse struct { type GamePlayResponse struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
GameID uuid.UUID `json:"game_id"` GameID uuid.UUID `json:"game_id"`
CustomerID uuid.UUID `json:"customer_id"` CustomerID uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id,omitempty"` PrizeID *uuid.UUID `json:"prize_id,omitempty"`
CoinsUsed int `json:"coins_used"` CoinsUsed int `json:"coins_used"`
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
TokenUsed int `json:"token_used"`
RandomSeed *string `json:"random_seed,omitempty"` RandomSeed *string `json:"random_seed,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Game *GameResponse `json:"game,omitempty"` Game *GameResponse `json:"game,omitempty"`
@@ -35,7 +33,7 @@ type ListGamePlaysRequest struct {
GameID *uuid.UUID `json:"game_id"` GameID *uuid.UUID `json:"game_id"`
CustomerID *uuid.UUID `json:"customer_id"` CustomerID *uuid.UUID `json:"customer_id"`
PrizeID *uuid.UUID `json:"prize_id"` PrizeID *uuid.UUID `json:"prize_id"`
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
@@ -58,6 +56,4 @@ type PlayGameResponse struct {
GamePlay GamePlayResponse `json:"game_play"` GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"` CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
} }
-2
View File
@@ -17,6 +17,4 @@ type SpinGameResponseData struct {
GamePlay GamePlayResponse `json:"game_play"` GamePlay GamePlayResponse `json:"game_play"`
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
CoinsRemaining int64 `json:"coins_remaining"` CoinsRemaining int64 `json:"coins_remaining"`
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
TokensRemaining int64 `json:"tokens_remaining"`
} }
-21
View File
@@ -16,27 +16,6 @@ const (
CampaignTypeMixed CampaignType = "MIXED" CampaignTypeMixed CampaignType = "MIXED"
) )
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
const legacyCampaignTokens = "TOKENS"
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
func NormalizeCampaignType(t string) CampaignType {
if t == legacyCampaignTokens {
return CampaignTypeCoins
}
return CampaignType(t)
}
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
// else as is.
func NormalizeCampaignRewardType(t string) CampaignRewardType {
if t == legacyCampaignTokens {
return CampaignRewardTypeCoins
}
return CampaignRewardType(t)
}
type RuleType string type RuleType string
const ( const (
-18
View File
@@ -1,18 +0,0 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
}
@@ -59,40 +59,6 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints") util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints")
} }
func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
ctx := c.Request.Context()
// Get customer ID from context (set by middleware)
customerID, exists := c.Get("customer_id")
if !exists {
logger.FromContext(ctx).Error("Customer ID not found in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
customerIDStr, ok := customerID.(string)
if !ok {
logger.FromContext(ctx).Error("Invalid customer ID type in context")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
response, err := h.customerPointsService.GetCustomerTokens(ctx, customerIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerTokens")
}
func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
+4 -8
View File
@@ -25,7 +25,7 @@ import (
) )
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app // Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
// gets the right numbers from the new endpoints and from the old /points, /tokens // gets the right numbers from the new endpoints and from the old /points
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see // and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go. // internal/repository/wallet_repository_test.go.
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
@@ -111,7 +111,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} { }{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
g := router.Group(who.prefix, as(who.id)) g := router.Group(who.prefix, as(who.id))
g.GET("/points", h.GetCustomerPoints) g.GET("/points", h.GetCustomerPoints)
g.GET("/tokens", h.GetCustomerTokens)
g.GET("/wallet", h.GetCustomerWallet) g.GET("/wallet", h.GetCustomerWallet)
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions) g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
} }
@@ -128,16 +127,12 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
return body["data"].(map[string]any)["data"].(map[string]any) return body["data"].(map[string]any)["data"].(map[string]any)
} }
// Old app: /points and /tokens keep their shape and show the spendable balance. // Old app: /points keeps its shape and shows the spendable balance.
status, body := get("/a/points") status, body := get("/a/points")
require.Equal(t, http.StatusOK, status, body) require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable") assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
assert.Len(t, inner(body)["points_history"], 3) assert.Len(t, inner(body)["points_history"], 3)
status, body = get("/a/tokens")
require.Equal(t, http.StatusOK, status, body)
assert.EqualValues(t, 8, inner(body)["total_tokens"])
// /wallet: new fields, with the old ones still there. // /wallet: new fields, with the old ones still there.
status, body = get("/a/wallet") status, body = get("/a/wallet")
require.Equal(t, http.StatusOK, status, body) require.Equal(t, http.StatusOK, status, body)
@@ -151,9 +146,10 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")}, assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days") nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
assert.Nil(t, nearest["coin"]) assert.Nil(t, nearest["coin"])
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} { for _, legacy := range []string{"total_points", "points_history", "last_updated"} {
assert.Contains(t, w, legacy) assert.Contains(t, w, legacy)
} }
assert.NotContains(t, w, "total_tokens", "tokens are gone; EnakCoin is coin_balance")
assert.EqualValues(t, 250, w["total_points"]) assert.EqualValues(t, 250, w["total_points"])
recent := w["recent_transactions"].([]any) recent := w["recent_transactions"].([]any)
+4 -4
View File
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
return &entities.Campaign{ return &entities.Campaign{
Name: request.Name, Name: request.Name,
Description: request.Description, Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type), Type: entities.CampaignType(request.Type),
StartDate: request.StartDate, StartDate: request.StartDate,
EndDate: request.EndDate, EndDate: request.EndDate,
IsActive: request.IsActive, IsActive: request.IsActive,
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
ID: request.ID, ID: request.ID,
Name: request.Name, Name: request.Name,
Description: request.Description, Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type), Type: entities.CampaignType(request.Type),
StartDate: request.StartDate, StartDate: request.StartDate,
EndDate: request.EndDate, EndDate: request.EndDate,
IsActive: request.IsActive, IsActive: request.IsActive,
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
CampaignID: campaignID, CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType), RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue, ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType), RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue, RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype, RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID, RewardRefID: request.RewardRefID,
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
CampaignID: campaignID, CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType), RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue, ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType), RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue, RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype, RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID, RewardRefID: request.RewardRefID,
+3 -5
View File
@@ -36,10 +36,9 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
} }
return &contract.SpinGameResponseData{ return &contract.SpinGameResponseData{
GamePlay: GamePlayResponseModelToContract(&data.GamePlay), GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
CoinsRemaining: data.CoinsRemaining, CoinsRemaining: data.CoinsRemaining,
TokensRemaining: data.CoinsRemaining,
} }
} }
@@ -55,7 +54,6 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
CustomerID: resp.CustomerID, CustomerID: resp.CustomerID,
PrizeID: resp.PrizeID, PrizeID: resp.PrizeID,
CoinsUsed: resp.CoinsUsed, CoinsUsed: resp.CoinsUsed,
TokenUsed: resp.CoinsUsed,
RandomSeed: resp.RandomSeed, RandomSeed: resp.RandomSeed,
CreatedAt: resp.CreatedAt, CreatedAt: resp.CreatedAt,
Game: nil, // Optional field - can be populated separately if needed Game: nil, // Optional field - can be populated separately if needed
+5 -5
View File
@@ -11,7 +11,7 @@ import (
type CreateCampaignRequest struct { type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
ID uuid.UUID `json:"id" binding:"required"` ID uuid.UUID `json:"id" binding:"required"`
Name string `json:"name" binding:"required,min=1,max=150"` Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"` Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
StartDate time.Time `json:"start_date" binding:"required"` StartDate time.Time `json:"start_date" binding:"required"`
EndDate time.Time `json:"end_date" binding:"required"` EndDate time.Time `json:"end_date" binding:"required"`
IsActive bool `json:"is_active"` IsActive bool `json:"is_active"`
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
CampaignID uuid.UUID `json:"campaign_id" binding:"required"` CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
type CampaignRuleStruct struct { type CampaignRuleStruct struct {
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
ConditionValue *string `json:"condition_value,omitempty"` ConditionValue *string `json:"condition_value,omitempty"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"` RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"` RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"` RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
-22
View File
@@ -25,26 +25,6 @@ type PointsHistoryItem struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type GetCustomerTokensResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
}
type GetCustomerTokensResponseData struct {
TotalTokens int64 `json:"total_tokens"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
type TokensHistoryItem struct {
ID string `json:"id"`
Tokens int64 `json:"tokens"`
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
}
type GetCustomerWalletResponse struct { type GetCustomerWalletResponse struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
@@ -66,8 +46,6 @@ type GetCustomerWalletResponseData struct {
// Deprecated: the shape of this endpoint before the wallet, kept for app versions // Deprecated: the shape of this endpoint before the wallet, kept for app versions
// that still read it. Filled from the wallet like the fields above. // that still read it. Filled from the wallet like the fields above.
TotalPoints int64 `json:"total_points"` TotalPoints int64 `json:"total_points"`
TotalTokens int64 `json:"total_tokens"`
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
LastUpdated time.Time `json:"last_updated"` LastUpdated time.Time `json:"last_updated"`
} }
+1 -1
View File
@@ -33,7 +33,7 @@ type ListGamePlaysQuery struct {
GameID *uuid.UUID `query:"game_id"` GameID *uuid.UUID `query:"game_id"`
CustomerID *uuid.UUID `query:"customer_id"` CustomerID *uuid.UUID `query:"customer_id"`
PrizeID *uuid.UUID `query:"prize_id"` PrizeID *uuid.UUID `query:"prize_id"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"` SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
} }
+4 -4
View File
@@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis
Page: req.Page, Page: req.Page,
Limit: req.Limit, Limit: req.Limit,
Search: req.Search, Search: req.Search,
Type: string(entities.NormalizeCampaignType(req.Type)), Type: req.Type,
IsActive: req.IsActive, IsActive: req.IsActive,
ShowOnApp: req.ShowOnApp, ShowOnApp: req.ShowOnApp,
StartDate: req.StartDate, StartDate: req.StartDate,
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType), RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue, ConditionValue: req.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(req.RewardType), RewardType: entities.CampaignRewardType(req.RewardType),
RewardValue: req.RewardValue, RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID, RewardRefID: req.RewardRefID,
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
Limit: req.Limit, Limit: req.Limit,
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: req.RuleType, RuleType: req.RuleType,
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)), RewardType: req.RewardType,
} }
// Get from repository // Get from repository
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
CampaignID: req.CampaignID, CampaignID: req.CampaignID,
RuleType: entities.RuleType(req.RuleType), RuleType: entities.RuleType(req.RuleType),
ConditionValue: req.ConditionValue, ConditionValue: req.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(req.RewardType), RewardType: entities.CampaignRewardType(req.RewardType),
RewardValue: req.RewardValue, RewardValue: req.RewardValue,
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
RewardRefID: req.RewardRefID, RewardRefID: req.RewardRefID,
@@ -22,7 +22,7 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep
} }
} }
// The three endpoints below are the customer app's balance screens. They read the // The endpoints below are the customer app's balance screens. They read the
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used // EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
// for balances (docs/prd-point-coin.md §9). // for balances (docs/prd-point-coin.md §9).
@@ -42,22 +42,6 @@ func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context,
}, nil }, nil
} }
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
id, err := parseWalletCustomerID(customerID)
if err != nil {
return nil, err
}
data, err := p.walletQuery.LegacyTokens(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
return &models.GetCustomerTokensResponse{
Status: "SUCCESS",
Message: "Customer tokens retrieved successfully.",
Data: data,
}, nil
}
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
id, err := parseWalletCustomerID(customerID) id, err := parseWalletCustomerID(customerID)
if err != nil { if err != nil {
@@ -99,10 +99,6 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
} }
offset := (query.Page - 1) * query.Limit offset := (query.Page - 1) * query.Limit
// The column was renamed; the old name still sorts the same way.
if query.SortBy == "token_used" {
query.SortBy = "coins_used"
}
// Get game plays from repository // Get game plays from repository
gamePlays, total, err := p.gamePlayRepo.List( gamePlays, total, err := p.gamePlayRepo.List(
+2 -35
View File
@@ -82,10 +82,6 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
if err != nil { if err != nil {
return nil, err return nil, err
} }
tokens, err := p.legacyTokens(ctx, customerID, balances)
if err != nil {
return nil, err
}
data := &models.GetCustomerWalletResponseData{ data := &models.GetCustomerWalletResponseData{
PointBalance: balances[constants.WalletCurrencyPoint], PointBalance: balances[constants.WalletCurrencyPoint],
@@ -94,13 +90,10 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue, PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
RecentTransactions: recent, RecentTransactions: recent,
TotalPoints: points.TotalPoints, TotalPoints: points.TotalPoints,
TotalTokens: tokens.TotalTokens,
PointsHistory: points.PointsHistory, PointsHistory: points.PointsHistory,
TokensHistory: tokens.TokensHistory,
LastUpdated: points.LastUpdated,
} }
if tokens.LastUpdated.After(data.LastUpdated) { if len(recent) > 0 {
data.LastUpdated = tokens.LastUpdated data.LastUpdated = recent[0].CreatedAt
} }
for _, e := range expiring { for _, e := range expiring {
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date} entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
@@ -161,32 +154,6 @@ func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid
return data, nil return data, nil
} }
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
if err != nil {
return nil, err
}
return p.legacyTokens(ctx, customerID, balances)
}
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
if err != nil {
return nil, err
}
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
for _, tx := range recent {
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
})
}
if len(recent) > 0 {
data.LastUpdated = recent[0].CreatedAt
}
return data, nil
}
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) { func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{ rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
CustomerID: customerID, Currency: currency, Limit: limit, CustomerID: customerID, Currency: currency, Limit: limit,
@@ -122,7 +122,6 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
// The pre-wallet fields are still filled, from the wallet. // The pre-wallet fields are still filled, from the wallet.
assert.Equal(t, int64(250), data.TotalPoints) assert.Equal(t, int64(250), data.TotalPoints)
assert.Equal(t, int64(8), data.TotalTokens)
require.Len(t, data.PointsHistory, 2) require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points) assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type) assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
@@ -174,12 +173,6 @@ func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
assert.Equal(t, int64(100), points.TotalPoints) assert.Equal(t, int64(100), points.TotalPoints)
require.Len(t, points.PointsHistory, 1) require.Len(t, points.PointsHistory, 1)
assert.Equal(t, int64(100), points.PointsHistory[0].Points) assert.Equal(t, int64(100), points.PointsHistory[0].Points)
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, int64(8), tokens.TotalTokens)
require.Len(t, tokens.TokensHistory, 1)
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
} }
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) { func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
-1
View File
@@ -169,7 +169,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.Use(r.customerAuthMiddleware.ValidateCustomerToken()) customer.Use(r.customerAuthMiddleware.ValidateCustomerToken())
{ {
customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) customer.GET("/points", r.customerPointsHandler.GetCustomerPoints)
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions) customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring) customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring)
+5 -5
View File
@@ -177,7 +177,7 @@ func (s *campaignService) CreateCampaignRule(ctx context.Context, req *contract.
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" {
if req.RewardValue == nil || *req.RewardValue <= 0 { if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType) return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
} }
@@ -247,7 +247,7 @@ func (s *campaignService) UpdateCampaignRule(ctx context.Context, req *contract.
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" { if req.RewardType == "POINTS" || req.RewardType == "COINS" {
if req.RewardValue == nil || *req.RewardValue <= 0 { if req.RewardValue == nil || *req.RewardValue <= 0 {
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType) return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
} }
@@ -295,7 +295,7 @@ func (s *campaignService) GetCampaignRulesByCampaignID(ctx context.Context, camp
} }
func (s *campaignService) validateCampaignType(campaignType string) error { func (s *campaignService) validateCampaignType(campaignType string) error {
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
for _, validType := range validTypes { for _, validType := range validTypes {
if campaignType == validType { if campaignType == validType {
return nil return nil
@@ -335,7 +335,7 @@ func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStr
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 { if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1) return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1)
} }
@@ -365,7 +365,7 @@ func (s *campaignService) validateRuleType(ruleType string) error {
} }
func (s *campaignService) validateRewardType(rewardType string) error { func (s *campaignService) validateRewardType(rewardType string) error {
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "REWARD"}
for _, validType := range validTypes { for _, validType := range validTypes {
if rewardType == validType { if rewardType == validType {
return nil return nil
@@ -10,7 +10,6 @@ import (
type CustomerPointsService interface { type CustomerPointsService interface {
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error)
@@ -41,19 +40,6 @@ func (s *customerPointsService) GetCustomerPoints(ctx context.Context, customerI
return response, nil return response, nil
} }
func (s *customerPointsService) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
if customerID == "" {
return nil, fmt.Errorf("customer ID is required")
}
response, err := s.customerPointsProcessor.GetCustomerTotalTokensAPI(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
}
return response, nil
}
func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
if customerID == "" { if customerID == "" {
return nil, fmt.Errorf("customer ID is required") return nil, fmt.Errorf("customer ID is required")
@@ -276,7 +276,6 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR
CustomerID: model.CustomerID, CustomerID: model.CustomerID,
PrizeID: model.PrizeID, PrizeID: model.PrizeID,
CoinsUsed: model.CoinsUsed, CoinsUsed: model.CoinsUsed,
TokenUsed: model.CoinsUsed,
RandomSeed: model.RandomSeed, RandomSeed: model.RandomSeed,
CreatedAt: model.CreatedAt, CreatedAt: model.CreatedAt,
Game: game, Game: game,
@@ -306,10 +305,9 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR
} }
return &contract.PlayGameResponse{ return &contract.PlayGameResponse{
GamePlay: gamePlayValue, GamePlay: gamePlayValue,
PrizeWon: prizeWon, PrizeWon: prizeWon,
CoinsRemaining: model.CoinsRemaining, CoinsRemaining: model.CoinsRemaining,
TokensRemaining: model.CoinsRemaining,
} }
} }
+3 -3
View File
@@ -254,7 +254,7 @@ func (v *CampaignValidatorImpl) ValidateDeleteCampaignRuleRequest(req *contract.
} }
func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool { func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool {
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"} validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
return contains(validTypes, campaignType) return contains(validTypes, campaignType)
} }
@@ -264,7 +264,7 @@ func (v *CampaignValidatorImpl) isValidRuleType(ruleType string) bool {
} }
func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool { func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool {
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"} validTypes := []string{"POINTS", "COINS", "REWARD"}
return contains(validTypes, rewardType) return contains(validTypes, rewardType)
} }
@@ -297,7 +297,7 @@ func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRule
} }
// Validate reward value based on reward type // Validate reward value based on reward type
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" { if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
if rule.RewardValue == nil || *rule.RewardValue <= 0 { if rule.RewardValue == nil || *rule.RewardValue <= 0 {
return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber))) return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber)))
} }