Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package admin
package discount

import (
"context"
Expand All @@ -11,6 +11,7 @@ import (
"github.com/jackc/pgx/v5"
"github.com/sitcon-tw/2026-game/internal/repository"
"github.com/sitcon-tw/2026-game/pkg/helpers"
"github.com/sitcon-tw/2026-game/pkg/middleware"
"github.com/sitcon-tw/2026-game/pkg/res"
)

Expand All @@ -25,10 +26,10 @@ var (
errScanLookupFailed = errors.New("scan target lookup failed")
)

// AssignCouponByQRCode handles POST /admin/discount-coupons/scan-assignments.
// @Summary 掃描使用者 QR code 發放折扣券
// @Description 需要 admin_token cookie。透過使用者的一次性 QR code 發放折扣券,並防止同一 user+discount 重複發放。
// @Tags admin
// AssignCouponByQRCode handles POST /discount-coupons/staff/scan-assignments.
// @Summary 掃描使用者 QR code 發放折扣券(工作人員)
// @Description 需要 staff_token cookie。透過使用者的一次性 QR code 發放折扣券,並防止同一 user+discount 重複發放。
// @Tags discount
// @Accept json
// @Produce json
// @Param request body assignCouponByQRCodeRequest true "Assign coupon by QR payload"
Expand All @@ -38,8 +39,14 @@ var (
// @Failure 404 {object} res.ErrorResponse "user not found"
// @Failure 409 {object} res.ErrorResponse "already issued by qr scan"
// @Failure 500 {object} res.ErrorResponse
// @Router /admin/discount-coupons/scan-assignments [post]
// @Router /discount-coupons/staff/scan-assignments [post]
func (h *Handler) AssignCouponByQRCode(w http.ResponseWriter, r *http.Request) {
staff, ok := middleware.StaffFromContext(r.Context())
if !ok || staff == nil {
res.Fail(w, r, http.StatusUnauthorized, errors.New("unauthorized"), "unauthorized staff")
return
}

var req assignCouponByQRCodeRequest
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
Expand Down Expand Up @@ -72,9 +79,9 @@ func (h *Handler) AssignCouponByQRCode(w http.ResponseWriter, r *http.Request) {
return
}

marked, err := h.Repo.TryMarkAdminScanCouponIssued(r.Context(), tx, userID, req.DiscountID)
marked, err := h.Repo.TryMarkStaffScanCouponIssued(r.Context(), tx, userID, req.DiscountID, staff.ID)
if err != nil {
res.Fail(w, r, http.StatusInternalServerError, err, "failed to mark admin scan issuance")
res.Fail(w, r, http.StatusInternalServerError, err, "failed to mark staff scan issuance")
return
}
if !marked {
Expand All @@ -98,6 +105,64 @@ func (h *Handler) AssignCouponByQRCode(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(coupon)
}

// ListStaffScanHistory handles GET /discount-coupons/staff/current/scan-assignments.
// @Summary 取得工作人員掃碼發券紀錄
// @Description 需要 staff_token cookie,回傳該 staff 透過掃碼發放折扣券的紀錄
// @Tags discount
// @Produce json
// @Success 200 {array} models.StaffQRCouponGrant
// @Failure 401 {object} res.ErrorResponse "unauthorized"
// @Failure 500 {object} res.ErrorResponse
// @Router /discount-coupons/staff/current/scan-assignments [get]
func (h *Handler) ListStaffScanHistory(w http.ResponseWriter, r *http.Request) {
staff, ok := middleware.StaffFromContext(r.Context())
if !ok || staff == nil {
res.Fail(w, r, http.StatusUnauthorized, errors.New("unauthorized"), "unauthorized staff")
return
}

tx, err := h.Repo.StartTransaction(r.Context())
if err != nil {
res.Fail(w, r, http.StatusInternalServerError, err, "failed to start transaction")
return
}
defer h.Repo.DeferRollback(r.Context(), tx)

grants, err := h.Repo.ListStaffScanCouponGrants(r.Context(), tx, staff.ID)
if err != nil {
res.Fail(w, r, http.StatusInternalServerError, err, "failed to list scan history")
return
}

if err = h.Repo.CommitTransaction(r.Context(), tx); err != nil {
res.Fail(w, r, http.StatusInternalServerError, err, "failed to commit transaction")
return
}

type scanGrantItem struct {
UserID string `json:"user_id"`
Nickname string `json:"nickname"`
DiscountID string `json:"discount_id"`
StaffID string `json:"staff_id"`
CreatedAt time.Time `json:"created_at"`
}

resp := make([]scanGrantItem, 0, len(grants))
for _, g := range grants {
resp = append(resp, scanGrantItem{
UserID: g.UserID,
Nickname: g.Nickname,
DiscountID: g.DiscountID,
StaffID: g.StaffID,
CreatedAt: g.CreatedAt,
})
}

w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(resp)
}

func (h *Handler) resolveUserIDFromOneTimeQRCode(ctx context.Context, tx pgx.Tx, token string) (string, error) {
userID, err := helpers.VerifyAndExtractUserIDFromOneTimeQRToken(
token,
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/models/staff_qr_coupon_grant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package models

import "time"

// StaffQRCouponGrant records a coupon issued by a staff member via QR code scan
// (table: staff_qr_coupon_grants).
type StaffQRCouponGrant struct {
UserID string `db:"user_id" json:"user_id"`
Nickname string `db:"nickname" json:"nickname"`
DiscountID string `db:"discount_id" json:"discount_id"`
StaffID string `db:"staff_id" json:"staff_id"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
21 changes: 0 additions & 21 deletions backend/internal/repository/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,24 +158,3 @@ LIMIT $2`

return users, nil
}

// TryMarkAdminScanCouponIssued marks scan issuance once per user+discount.
// Returns true when marked, false when already marked before.
func (r *PGRepository) TryMarkAdminScanCouponIssued(
ctx context.Context,
tx pgx.Tx,
userID string,
discountID string,
) (bool, error) {
const stmt = `
INSERT INTO admin_qr_coupon_grants (user_id, discount_id, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, discount_id) DO NOTHING`

tag, err := tx.Exec(ctx, stmt, userID, discountID)
if err != nil {
return false, err
}

return tag.RowsAffected() == 1, nil
}
8 changes: 7 additions & 1 deletion backend/internal/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,18 @@ type Repository interface {
DeleteDiscountCouponGiftByID(ctx context.Context, tx pgx.Tx, id string) error
ListDiscountCouponGifts(ctx context.Context, tx pgx.Tx) ([]models.DiscountCouponGift, error)
SearchUsersByNickname(ctx context.Context, tx pgx.Tx, query string, limit int) ([]models.User, error)
TryMarkAdminScanCouponIssued(
TryMarkStaffScanCouponIssued(
ctx context.Context,
tx pgx.Tx,
userID string,
discountID string,
staffID string,
) (bool, error)
ListStaffScanCouponGrants(
ctx context.Context,
tx pgx.Tx,
staffID string,
) ([]models.StaffQRCouponGrant, error)

// Staff operations
GetStaffByToken(ctx context.Context, tx pgx.Tx, token string) (*models.Staff, error)
Expand Down
65 changes: 65 additions & 0 deletions backend/internal/repository/staff.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,68 @@ FOR UPDATE`
}
return &s, nil
}

// TryMarkStaffScanCouponIssued marks scan issuance once per user+discount (by a staff member).
// Returns true when newly inserted, false when already issued.
func (r *PGRepository) TryMarkStaffScanCouponIssued(
ctx context.Context,
tx pgx.Tx,
userID string,
discountID string,
staffID string,
) (bool, error) {
const stmt = `
INSERT INTO staff_qr_coupon_grants (user_id, discount_id, staff_id, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, discount_id) DO NOTHING`

tag, err := tx.Exec(ctx, stmt, userID, discountID, staffID)
if err != nil {
return false, err
}

return tag.RowsAffected() == 1, nil
}

// ListStaffScanCouponGrants returns all QR-scan coupon grants performed by a given staff member.
func (r *PGRepository) ListStaffScanCouponGrants(
ctx context.Context,
tx pgx.Tx,
staffID string,
) ([]models.StaffQRCouponGrant, error) {
const query = `
SELECT g.user_id,
u.nickname,
g.discount_id,
g.staff_id,
g.created_at
FROM staff_qr_coupon_grants g
LEFT JOIN users u ON u.id = g.user_id
WHERE g.staff_id = $1
ORDER BY g.created_at DESC`

rows, err := tx.Query(ctx, query, staffID)
if err != nil {
return nil, err
}
defer rows.Close()

var grants []models.StaffQRCouponGrant
for rows.Next() {
var g models.StaffQRCouponGrant
if scanErr := rows.Scan(
&g.UserID,
&g.Nickname,
&g.DiscountID,
&g.StaffID,
&g.CreatedAt,
); scanErr != nil {
return nil, scanErr
}
grants = append(grants, g)
}
if err = rows.Err(); err != nil {
return nil, err
}
return grants, nil
}
1 change: 0 additions & 1 deletion backend/internal/router/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ func AdminRoutes(repo repository.Repository, logger *zap.Logger) http.Handler {
// Legacy alias: kept for backward compatibility.
r.Post("/gift-coupons/assignments", h.AssignCouponToUser)
r.Post("/discount-coupons/assignments", h.AssignCouponToUser)
r.Post("/discount-coupons/scan-assignments", h.AssignCouponByQRCode)
r.Get("/users", h.SearchUsers)
})

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/router/discount.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ func DiscountRoutes(repo repository.Repository, logger *zap.Logger) http.Handler
r.Post("/coupon-tokens/query", h.GetUserCoupons)
// Staff sees their own redemption history
r.Get("/current/redemptions", h.ListStaffHistory)
// Staff scans attendee's one-time QR code to issue a coupon
r.Post("/scan-assignments", h.AssignCouponByQRCode)
// Staff sees their own scan-assignment history
r.Get("/current/scan-assignments", h.ListStaffScanHistory)
})
})
// Publicly lists all coupon rules with issuance status
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
DROP TABLE IF EXISTS "public"."admin_qr_coupon_grants";

CREATE TABLE "public"."staff_qr_coupon_grants" (
"user_id" uuid NOT NULL,
"discount_id" text NOT NULL,
"staff_id" uuid NOT NULL,
"created_at" timestamp NOT NULL,
CONSTRAINT "pk_staff_qr_coupon_grants_user_discount" PRIMARY KEY ("user_id", "discount_id")
);

ALTER TABLE "public"."staff_qr_coupon_grants"
ADD CONSTRAINT "fk_staff_qr_coupon_grants_user_id_users_id"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id");

ALTER TABLE "public"."staff_qr_coupon_grants"
ADD CONSTRAINT "fk_staff_qr_coupon_grants_staff_id_staffs_id"
FOREIGN KEY ("staff_id") REFERENCES "public"."staffs"("id");

CREATE INDEX "idx_staff_qr_coupon_grants_staff_id" ON "public"."staff_qr_coupon_grants" ("staff_id");
Loading