Skip to content

Commit 8d32883

Browse files
Merge pull request #58 from sitcon-tw/dev/backend
Move IG share's coupon scan from admin to staff
2 parents 3ee17f1 + 140d09b commit 8d32883

File tree

8 files changed

+181
-31
lines changed

8 files changed

+181
-31
lines changed

backend/internal/handler/admin/assign_coupon_by_qrcode.go renamed to backend/internal/handler/discount/assign_coupon_by_qrcode.go

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package admin
1+
package discount
22

33
import (
44
"context"
@@ -11,6 +11,7 @@ import (
1111
"github.com/jackc/pgx/v5"
1212
"github.com/sitcon-tw/2026-game/internal/repository"
1313
"github.com/sitcon-tw/2026-game/pkg/helpers"
14+
"github.com/sitcon-tw/2026-game/pkg/middleware"
1415
"github.com/sitcon-tw/2026-game/pkg/res"
1516
)
1617

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

28-
// AssignCouponByQRCode handles POST /admin/discount-coupons/scan-assignments.
29-
// @Summary 掃描使用者 QR code 發放折扣券
30-
// @Description 需要 admin_token cookie。透過使用者的一次性 QR code 發放折扣券,並防止同一 user+discount 重複發放。
31-
// @Tags admin
29+
// AssignCouponByQRCode handles POST /discount-coupons/staff/scan-assignments.
30+
// @Summary 掃描使用者 QR code 發放折扣券(工作人員)
31+
// @Description 需要 staff_token cookie。透過使用者的一次性 QR code 發放折扣券,並防止同一 user+discount 重複發放。
32+
// @Tags discount
3233
// @Accept json
3334
// @Produce json
3435
// @Param request body assignCouponByQRCodeRequest true "Assign coupon by QR payload"
@@ -38,8 +39,14 @@ var (
3839
// @Failure 404 {object} res.ErrorResponse "user not found"
3940
// @Failure 409 {object} res.ErrorResponse "already issued by qr scan"
4041
// @Failure 500 {object} res.ErrorResponse
41-
// @Router /admin/discount-coupons/scan-assignments [post]
42+
// @Router /discount-coupons/staff/scan-assignments [post]
4243
func (h *Handler) AssignCouponByQRCode(w http.ResponseWriter, r *http.Request) {
44+
staff, ok := middleware.StaffFromContext(r.Context())
45+
if !ok || staff == nil {
46+
res.Fail(w, r, http.StatusUnauthorized, errors.New("unauthorized"), "unauthorized staff")
47+
return
48+
}
49+
4350
var req assignCouponByQRCodeRequest
4451
decoder := json.NewDecoder(r.Body)
4552
decoder.DisallowUnknownFields()
@@ -72,9 +79,9 @@ func (h *Handler) AssignCouponByQRCode(w http.ResponseWriter, r *http.Request) {
7279
return
7380
}
7481

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

108+
// ListStaffScanHistory handles GET /discount-coupons/staff/current/scan-assignments.
109+
// @Summary 取得工作人員掃碼發券紀錄
110+
// @Description 需要 staff_token cookie,回傳該 staff 透過掃碼發放折扣券的紀錄
111+
// @Tags discount
112+
// @Produce json
113+
// @Success 200 {array} models.StaffQRCouponGrant
114+
// @Failure 401 {object} res.ErrorResponse "unauthorized"
115+
// @Failure 500 {object} res.ErrorResponse
116+
// @Router /discount-coupons/staff/current/scan-assignments [get]
117+
func (h *Handler) ListStaffScanHistory(w http.ResponseWriter, r *http.Request) {
118+
staff, ok := middleware.StaffFromContext(r.Context())
119+
if !ok || staff == nil {
120+
res.Fail(w, r, http.StatusUnauthorized, errors.New("unauthorized"), "unauthorized staff")
121+
return
122+
}
123+
124+
tx, err := h.Repo.StartTransaction(r.Context())
125+
if err != nil {
126+
res.Fail(w, r, http.StatusInternalServerError, err, "failed to start transaction")
127+
return
128+
}
129+
defer h.Repo.DeferRollback(r.Context(), tx)
130+
131+
grants, err := h.Repo.ListStaffScanCouponGrants(r.Context(), tx, staff.ID)
132+
if err != nil {
133+
res.Fail(w, r, http.StatusInternalServerError, err, "failed to list scan history")
134+
return
135+
}
136+
137+
if err = h.Repo.CommitTransaction(r.Context(), tx); err != nil {
138+
res.Fail(w, r, http.StatusInternalServerError, err, "failed to commit transaction")
139+
return
140+
}
141+
142+
type scanGrantItem struct {
143+
UserID string `json:"user_id"`
144+
Nickname string `json:"nickname"`
145+
DiscountID string `json:"discount_id"`
146+
StaffID string `json:"staff_id"`
147+
CreatedAt time.Time `json:"created_at"`
148+
}
149+
150+
resp := make([]scanGrantItem, 0, len(grants))
151+
for _, g := range grants {
152+
resp = append(resp, scanGrantItem{
153+
UserID: g.UserID,
154+
Nickname: g.Nickname,
155+
DiscountID: g.DiscountID,
156+
StaffID: g.StaffID,
157+
CreatedAt: g.CreatedAt,
158+
})
159+
}
160+
161+
w.Header().Set("Content-Type", "application/json; charset=utf-8")
162+
w.WriteHeader(http.StatusOK)
163+
_ = json.NewEncoder(w).Encode(resp)
164+
}
165+
101166
func (h *Handler) resolveUserIDFromOneTimeQRCode(ctx context.Context, tx pgx.Tx, token string) (string, error) {
102167
userID, err := helpers.VerifyAndExtractUserIDFromOneTimeQRToken(
103168
token,
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package models
2+
3+
import "time"
4+
5+
// StaffQRCouponGrant records a coupon issued by a staff member via QR code scan
6+
// (table: staff_qr_coupon_grants).
7+
type StaffQRCouponGrant struct {
8+
UserID string `db:"user_id" json:"user_id"`
9+
Nickname string `db:"nickname" json:"nickname"`
10+
DiscountID string `db:"discount_id" json:"discount_id"`
11+
StaffID string `db:"staff_id" json:"staff_id"`
12+
CreatedAt time.Time `db:"created_at" json:"created_at"`
13+
}

backend/internal/repository/admin.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -158,24 +158,3 @@ LIMIT $2`
158158

159159
return users, nil
160160
}
161-
162-
// TryMarkAdminScanCouponIssued marks scan issuance once per user+discount.
163-
// Returns true when marked, false when already marked before.
164-
func (r *PGRepository) TryMarkAdminScanCouponIssued(
165-
ctx context.Context,
166-
tx pgx.Tx,
167-
userID string,
168-
discountID string,
169-
) (bool, error) {
170-
const stmt = `
171-
INSERT INTO admin_qr_coupon_grants (user_id, discount_id, created_at)
172-
VALUES ($1, $2, NOW())
173-
ON CONFLICT (user_id, discount_id) DO NOTHING`
174-
175-
tag, err := tx.Exec(ctx, stmt, userID, discountID)
176-
if err != nil {
177-
return false, err
178-
}
179-
180-
return tag.RowsAffected() == 1, nil
181-
}

backend/internal/repository/repository.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,18 @@ type Repository interface {
101101
DeleteDiscountCouponGiftByID(ctx context.Context, tx pgx.Tx, id string) error
102102
ListDiscountCouponGifts(ctx context.Context, tx pgx.Tx) ([]models.DiscountCouponGift, error)
103103
SearchUsersByNickname(ctx context.Context, tx pgx.Tx, query string, limit int) ([]models.User, error)
104-
TryMarkAdminScanCouponIssued(
104+
TryMarkStaffScanCouponIssued(
105105
ctx context.Context,
106106
tx pgx.Tx,
107107
userID string,
108108
discountID string,
109+
staffID string,
109110
) (bool, error)
111+
ListStaffScanCouponGrants(
112+
ctx context.Context,
113+
tx pgx.Tx,
114+
staffID string,
115+
) ([]models.StaffQRCouponGrant, error)
110116

111117
// Staff operations
112118
GetStaffByToken(ctx context.Context, tx pgx.Tx, token string) (*models.Staff, error)

backend/internal/repository/staff.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,68 @@ FOR UPDATE`
3131
}
3232
return &s, nil
3333
}
34+
35+
// TryMarkStaffScanCouponIssued marks scan issuance once per user+discount (by a staff member).
36+
// Returns true when newly inserted, false when already issued.
37+
func (r *PGRepository) TryMarkStaffScanCouponIssued(
38+
ctx context.Context,
39+
tx pgx.Tx,
40+
userID string,
41+
discountID string,
42+
staffID string,
43+
) (bool, error) {
44+
const stmt = `
45+
INSERT INTO staff_qr_coupon_grants (user_id, discount_id, staff_id, created_at)
46+
VALUES ($1, $2, $3, NOW())
47+
ON CONFLICT (user_id, discount_id) DO NOTHING`
48+
49+
tag, err := tx.Exec(ctx, stmt, userID, discountID, staffID)
50+
if err != nil {
51+
return false, err
52+
}
53+
54+
return tag.RowsAffected() == 1, nil
55+
}
56+
57+
// ListStaffScanCouponGrants returns all QR-scan coupon grants performed by a given staff member.
58+
func (r *PGRepository) ListStaffScanCouponGrants(
59+
ctx context.Context,
60+
tx pgx.Tx,
61+
staffID string,
62+
) ([]models.StaffQRCouponGrant, error) {
63+
const query = `
64+
SELECT g.user_id,
65+
u.nickname,
66+
g.discount_id,
67+
g.staff_id,
68+
g.created_at
69+
FROM staff_qr_coupon_grants g
70+
LEFT JOIN users u ON u.id = g.user_id
71+
WHERE g.staff_id = $1
72+
ORDER BY g.created_at DESC`
73+
74+
rows, err := tx.Query(ctx, query, staffID)
75+
if err != nil {
76+
return nil, err
77+
}
78+
defer rows.Close()
79+
80+
var grants []models.StaffQRCouponGrant
81+
for rows.Next() {
82+
var g models.StaffQRCouponGrant
83+
if scanErr := rows.Scan(
84+
&g.UserID,
85+
&g.Nickname,
86+
&g.DiscountID,
87+
&g.StaffID,
88+
&g.CreatedAt,
89+
); scanErr != nil {
90+
return nil, scanErr
91+
}
92+
grants = append(grants, g)
93+
}
94+
if err = rows.Err(); err != nil {
95+
return nil, err
96+
}
97+
return grants, nil
98+
}

backend/internal/router/admin.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ func AdminRoutes(repo repository.Repository, logger *zap.Logger) http.Handler {
2626
// Legacy alias: kept for backward compatibility.
2727
r.Post("/gift-coupons/assignments", h.AssignCouponToUser)
2828
r.Post("/discount-coupons/assignments", h.AssignCouponToUser)
29-
r.Post("/discount-coupons/scan-assignments", h.AssignCouponByQRCode)
3029
r.Get("/users", h.SearchUsers)
3130
})
3231

backend/internal/router/discount.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ func DiscountRoutes(repo repository.Repository, logger *zap.Logger) http.Handler
2828
r.Post("/coupon-tokens/query", h.GetUserCoupons)
2929
// Staff sees their own redemption history
3030
r.Get("/current/redemptions", h.ListStaffHistory)
31+
// Staff scans attendee's one-time QR code to issue a coupon
32+
r.Post("/scan-assignments", h.AssignCouponByQRCode)
33+
// Staff sees their own scan-assignment history
34+
r.Get("/current/scan-assignments", h.ListStaffScanHistory)
3135
})
3236
})
3337
// Publicly lists all coupon rules with issuance status
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
DROP TABLE IF EXISTS "public"."admin_qr_coupon_grants";
2+
3+
CREATE TABLE "public"."staff_qr_coupon_grants" (
4+
"user_id" uuid NOT NULL,
5+
"discount_id" text NOT NULL,
6+
"staff_id" uuid NOT NULL,
7+
"created_at" timestamp NOT NULL,
8+
CONSTRAINT "pk_staff_qr_coupon_grants_user_discount" PRIMARY KEY ("user_id", "discount_id")
9+
);
10+
11+
ALTER TABLE "public"."staff_qr_coupon_grants"
12+
ADD CONSTRAINT "fk_staff_qr_coupon_grants_user_id_users_id"
13+
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id");
14+
15+
ALTER TABLE "public"."staff_qr_coupon_grants"
16+
ADD CONSTRAINT "fk_staff_qr_coupon_grants_staff_id_staffs_id"
17+
FOREIGN KEY ("staff_id") REFERENCES "public"."staffs"("id");
18+
19+
CREATE INDEX "idx_staff_qr_coupon_grants_staff_id" ON "public"."staff_qr_coupon_grants" ("staff_id");

0 commit comments

Comments
 (0)