feat: add partner interface, admin dashboard, and unit tests

Partner flow: QR scanner component (html5-qrcode) with camera
permission/not-found handling, 3-step spend flow (scan → amount
entry with auto-filled max → success/error result).

Admin dashboard: stats overview, grant points with debounced
student search, paginated transactions table with type filters,
paginated students table.

Tests: comprehensive unit tests for points and auth packages —
service (all paths including error branches, RS256 wrong-method),
handler (all HTTP status codes via httptest), JWT round-trip,
repository constructors. Auth coverage: 72.9%, points service
coverage: 100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-01 16:41:17 +03:00
co-authored by Claude Sonnet 4.6
parent 50b3c4198a
commit be2260d259
21 changed files with 2259 additions and 73 deletions
+13 -11
View File
@@ -74,14 +74,15 @@ type SpendRequest struct {
// SpendPoints debits the given amount from the student's balance
// and records a spend transaction atomically in a single DB transaction.
// Returns the student's new balance on success.
// 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 {
func (s *Service) SpendPoints(ctx context.Context, req SpendRequest) (int, error) {
// Step 1: validate QR JWT and extract student_id and jti.
claims, err := s.parseQRToken(req.QRToken)
if err != nil {
return ErrInvalidQRToken
return 0, ErrInvalidQRToken
}
studentID := claims.Subject
jti := claims.ID
@@ -89,40 +90,41 @@ func (s *Service) SpendPoints(ctx context.Context, req SpendRequest) error {
// 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)
return 0, fmt.Errorf("service.SpendPoints: cache check: %w", err)
}
if used {
return ErrQRAlreadyUsed
return 0, 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)
return 0, fmt.Errorf("service.SpendPoints: get balance: %w", err)
}
if balance < req.Amount {
return ErrInsufficientBalance
return 0, 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 {
newBalance, err := s.repo.SpendAtomic(ctx, studentID, req.PartnerID, req.Amount)
if err != nil {
// Propagate balance constraint violation with a domain error.
if isConstraintError(err) {
return ErrInsufficientBalance
return 0, ErrInsufficientBalance
}
return fmt.Errorf("service.SpendPoints: spend atomic: %w", err)
return 0, 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 0, fmt.Errorf("service.SpendPoints: mark qr used: %w", err)
}
return nil
return newBalance, nil
}
// GenerateQRToken creates a one-time JWT for the student to present at a partner terminal.