feat: initial commit — backend API + student cabinet frontend
- Go backend: auth (JWT), points earn/spend, QR token generation, partners, admin grant/stats endpoints with chi router - Next.js 14 frontend: login, student dashboard, transaction history, QR display, partners list - PostgreSQL migrations (4 tables), Redis cache, Docker Compose - CORS middleware, role-based route protection, Zustand auth store Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
// Package points handles earning and spending of loyalty points.
|
||||
package points
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/cu-points/backend/internal/middleware"
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the points domain.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new points Handler.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// qrResponse is the JSON body returned by GenerateQR.
|
||||
type qrResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// spendRequest is the expected JSON body for POST /api/v1/partner/spend.
|
||||
type spendRequest struct {
|
||||
QRToken string `json:"qr_token"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
|
||||
// GenerateQR handles GET /api/v1/me/qr.
|
||||
// Returns a one-time QR JWT token with 5-minute TTL for the authenticated student.
|
||||
func (h *Handler) GenerateQR(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
token, err := h.service.GenerateQRToken(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("handler.GenerateQR", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, qrResponse{Token: token})
|
||||
}
|
||||
|
||||
// Spend handles POST /api/v1/partner/spend.
|
||||
// Accepts {qr_token, amount}; debits student balance atomically.
|
||||
// Requires role=partner (enforced by the router's RequireRole middleware).
|
||||
func (h *Handler) Spend(w http.ResponseWriter, r *http.Request) {
|
||||
var req spendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.QRToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "qr_token is required")
|
||||
return
|
||||
}
|
||||
if req.Amount <= 0 {
|
||||
response.Error(w, http.StatusBadRequest, "amount must be positive")
|
||||
return
|
||||
}
|
||||
|
||||
partnerID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
err := h.service.SpendPoints(r.Context(), SpendRequest{
|
||||
QRToken: req.QRToken,
|
||||
Amount: req.Amount,
|
||||
PartnerID: partnerID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidQRToken):
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired QR token")
|
||||
case errors.Is(err, ErrQRAlreadyUsed):
|
||||
response.Error(w, http.StatusConflict, "QR token has already been used")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(w, http.StatusUnprocessableEntity, "insufficient balance")
|
||||
default:
|
||||
slog.Error("handler.Spend", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package points
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Repository defines the database operations needed by the points service.
|
||||
// Using an interface allows the service to be unit-tested with a mock implementation.
|
||||
type Repository interface {
|
||||
// GetBalance fetches the current balance for the given user.
|
||||
GetBalance(ctx context.Context, userID string) (int, error)
|
||||
// EarnAtomic credits amount to the user's balance and inserts a transaction row
|
||||
// in a single DB transaction. txType must be "earn" or "admin_grant".
|
||||
EarnAtomic(ctx context.Context, userID string, amount int, txType, description string) error
|
||||
// SpendAtomic debits amount from user balance and inserts a spend transaction
|
||||
// in a single database transaction. The DB CHECK (balance >= 0) is the
|
||||
// authoritative guard; the service also pre-checks to return ErrInsufficientBalance early.
|
||||
SpendAtomic(ctx context.Context, userID, partnerID string, amount int) error
|
||||
}
|
||||
|
||||
// CacheClient defines the Redis operations needed by the points service.
|
||||
type CacheClient interface {
|
||||
// IsQRUsed returns true if the given jti has already been redeemed.
|
||||
IsQRUsed(ctx context.Context, jti string) (bool, error)
|
||||
// MarkQRUsed records the jti as used with a TTL of 5 minutes.
|
||||
MarkQRUsed(ctx context.Context, jti string) error
|
||||
}
|
||||
|
||||
// pgRepository is the PostgreSQL-backed implementation of Repository.
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new PostgreSQL-backed points Repository.
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
// GetBalance returns the current point balance for the given user.
|
||||
func (r *pgRepository) GetBalance(ctx context.Context, userID string) (int, error) {
|
||||
var balance int
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT balance FROM users WHERE id = $1`,
|
||||
userID,
|
||||
).Scan(&balance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("repository.GetBalance: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// EarnAtomic credits amount to the user's balance and records a transaction,
|
||||
// all within a single DB transaction.
|
||||
// txType must be a value accepted by the transactions.type CHECK constraint ("earn" or "admin_grant").
|
||||
func (r *pgRepository) EarnAtomic(ctx context.Context, userID string, amount int, txType, description string) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`UPDATE users SET balance = balance + $1 WHERE id = $2`,
|
||||
amount, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: update balance: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO transactions (user_id, amount, type, description)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
userID, amount, txType, description,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: insert transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendAtomic deducts amount from the student's balance and records a spend
|
||||
// transaction, all within a single DB transaction.
|
||||
// The negative amount stored in transactions follows the ledger convention:
|
||||
// positive = earn, negative = spend.
|
||||
func (r *pgRepository) SpendAtomic(ctx context.Context, userID, partnerID string, amount int) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var newBalance int
|
||||
err = tx.QueryRow(ctx,
|
||||
`UPDATE users SET balance = balance - $1 WHERE id = $2 RETURNING balance`,
|
||||
amount, userID,
|
||||
).Scan(&newBalance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: update balance: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO transactions (user_id, partner_id, amount, type)
|
||||
VALUES ($1, $2, $3, 'spend')`,
|
||||
userID, partnerID, -amount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: insert transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// redisCache is the Redis-backed implementation of CacheClient.
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache creates a Redis-backed CacheClient for QR token one-time-use tracking.
|
||||
func NewRedisCache(client *redis.Client) CacheClient {
|
||||
return &redisCache{client: client}
|
||||
}
|
||||
|
||||
// IsQRUsed returns true if the given jti key already exists in Redis.
|
||||
func (c *redisCache) IsQRUsed(ctx context.Context, jti string) (bool, error) {
|
||||
count, err := c.client.Exists(ctx, "used_qr:"+jti).Result()
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// MarkQRUsed sets used_qr:<jti> = "1" with a 5-minute TTL.
|
||||
// TTL matches the QR token expiry so the key is automatically cleaned up.
|
||||
func (c *redisCache) MarkQRUsed(ctx context.Context, jti string) error {
|
||||
return c.client.Set(ctx, "used_qr:"+jti, "1", 5*time.Minute).Err()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package points
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ErrInsufficientBalance is returned when a student's balance is lower than the requested spend amount.
|
||||
var ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
|
||||
// ErrQRAlreadyUsed is returned when a QR token has already been redeemed.
|
||||
var ErrQRAlreadyUsed = errors.New("QR token already used")
|
||||
|
||||
// ErrInvalidQRToken is returned when the QR JWT is malformed, expired, or has the wrong type.
|
||||
var ErrInvalidQRToken = errors.New("invalid or expired QR token")
|
||||
|
||||
const qrTokenTTL = 5 * time.Minute
|
||||
|
||||
// qrClaims are the JWT payload fields for one-time QR spend tokens.
|
||||
type qrClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Type string `json:"type"` // always "qr"
|
||||
}
|
||||
|
||||
// Service contains the critical business logic for points operations.
|
||||
// This is the most important file in the project — all balance mutations live here.
|
||||
type Service struct {
|
||||
repo Repository
|
||||
cache CacheClient
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewService creates a new points Service.
|
||||
// secret must be the same HMAC secret used for all JWTs in this application.
|
||||
func NewService(repo Repository, cache CacheClient, secret string) *Service {
|
||||
return &Service{repo: repo, cache: cache, secret: []byte(secret)}
|
||||
}
|
||||
|
||||
// EarnRequest holds the data needed to credit a student's balance.
|
||||
// Type must be "earn" or "admin_grant" — enforced by the DB CHECK constraint.
|
||||
type EarnRequest struct {
|
||||
UserID string
|
||||
Amount int
|
||||
Type string // "earn" or "admin_grant"
|
||||
Description string
|
||||
}
|
||||
|
||||
// EarnPoints credits the given amount to the student's balance and records a
|
||||
// transaction of the specified type atomically. This is the single place where
|
||||
// all credit logic lives — admin grants, future LMS integrations, etc. must
|
||||
// call this method rather than touching the DB directly.
|
||||
func (s *Service) EarnPoints(ctx context.Context, req EarnRequest) error {
|
||||
if req.Amount <= 0 {
|
||||
return fmt.Errorf("service.EarnPoints: amount must be positive")
|
||||
}
|
||||
if err := s.repo.EarnAtomic(ctx, req.UserID, req.Amount, req.Type, req.Description); err != nil {
|
||||
return fmt.Errorf("service.EarnPoints: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendRequest holds the data needed to debit a student's balance at a partner.
|
||||
type SpendRequest struct {
|
||||
QRToken string
|
||||
Amount int
|
||||
PartnerID string
|
||||
}
|
||||
|
||||
// SpendPoints debits the given amount from the student's balance
|
||||
// and records a spend transaction atomically in a single DB transaction.
|
||||
// Returns ErrInvalidQRToken if the token is malformed or expired.
|
||||
// Returns ErrQRAlreadyUsed if the QR token has been redeemed before.
|
||||
// Returns ErrInsufficientBalance if balance < amount.
|
||||
func (s *Service) SpendPoints(ctx context.Context, req SpendRequest) error {
|
||||
// Step 1: validate QR JWT and extract student_id and jti.
|
||||
claims, err := s.parseQRToken(req.QRToken)
|
||||
if err != nil {
|
||||
return ErrInvalidQRToken
|
||||
}
|
||||
studentID := claims.Subject
|
||||
jti := claims.ID
|
||||
|
||||
// Step 2: one-time-use check — reject if already redeemed.
|
||||
used, err := s.cache.IsQRUsed(ctx, jti)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service.SpendPoints: cache check: %w", err)
|
||||
}
|
||||
if used {
|
||||
return ErrQRAlreadyUsed
|
||||
}
|
||||
|
||||
// Step 3: pre-check balance for a clear error message before hitting the DB.
|
||||
// The DB CHECK (balance >= 0) is the authoritative guard; this is a fast-fail.
|
||||
balance, err := s.repo.GetBalance(ctx, studentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("service.SpendPoints: get balance: %w", err)
|
||||
}
|
||||
if balance < req.Amount {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
|
||||
// Step 4–5: debit balance and insert spend transaction atomically.
|
||||
// SpendAtomic uses a DB transaction; the balance CHECK constraint is the
|
||||
// last line of defence against concurrent overdrafts.
|
||||
if err := s.repo.SpendAtomic(ctx, studentID, req.PartnerID, req.Amount); err != nil {
|
||||
// Propagate balance constraint violation with a domain error.
|
||||
if isConstraintError(err) {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
return fmt.Errorf("service.SpendPoints: spend atomic: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: mark token as used only after the DB commit succeeds.
|
||||
// If MarkQRUsed fails, the spend already committed — log but don't rollback.
|
||||
if err := s.cache.MarkQRUsed(ctx, jti); err != nil {
|
||||
return fmt.Errorf("service.SpendPoints: mark qr used: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateQRToken creates a one-time JWT for the student to present at a partner terminal.
|
||||
// The token encodes the student's user_id and a unique jti; TTL is 5 minutes.
|
||||
func (s *Service) GenerateQRToken(ctx context.Context, userID string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service.GenerateQRToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := qrClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(qrTokenTTL)),
|
||||
},
|
||||
Type: "qr",
|
||||
}
|
||||
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service.GenerateQRToken: sign: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// parseQRToken validates the JWT signature/expiry and asserts type="qr".
|
||||
func (s *Service) parseQRToken(tokenStr string) (*qrClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &qrClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return s.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
claims, ok := token.Claims.(*qrClaims)
|
||||
if !ok || claims.Type != "qr" {
|
||||
return nil, fmt.Errorf("not a QR token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// newJTI generates a cryptographically random UUID v4 string for use as a JWT ID.
|
||||
func newJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
|
||||
// isConstraintError reports whether err contains a PostgreSQL balance CHECK violation.
|
||||
func isConstraintError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "check")
|
||||
}
|
||||
Reference in New Issue
Block a user