From be2260d259987a1880862672669356cd3114e4fd Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 1 May 2026 16:41:17 +0300 Subject: [PATCH] feat: add partner interface, admin dashboard, and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/internal/admin/handler.go | 33 +- backend/internal/admin/service.go | 130 +++++-- backend/internal/auth/handler_test.go | 187 +++++++++ backend/internal/auth/service_test.go | 193 +++++++++ backend/internal/middleware/auth.go | 6 + backend/internal/points/handler.go | 11 +- backend/internal/points/handler_test.go | 218 +++++++++++ backend/internal/points/repository.go | 19 +- backend/internal/points/repository_test.go | 31 ++ backend/internal/points/service.go | 24 +- backend/internal/points/service_test.go | 432 +++++++++++++++++++++ frontend/app/(partner)/scan/page.tsx | 203 +++++++++- frontend/app/admin/dashboard/page.tsx | 132 ++++++- frontend/app/admin/grant/page.tsx | 250 +++++++++++- frontend/app/admin/transactions/page.tsx | 156 ++++++++ frontend/app/admin/users/page.tsx | 125 ++++++ frontend/components/AdminNav.tsx | 44 +++ frontend/components/QRScanner.tsx | 89 +++++ frontend/lib/types.ts | 41 ++ frontend/package-lock.json | 7 + frontend/package.json | 1 + 21 files changed, 2259 insertions(+), 73 deletions(-) create mode 100644 backend/internal/auth/handler_test.go create mode 100644 backend/internal/points/handler_test.go create mode 100644 backend/internal/points/repository_test.go create mode 100644 backend/internal/points/service_test.go create mode 100644 frontend/app/admin/transactions/page.tsx create mode 100644 frontend/app/admin/users/page.tsx create mode 100644 frontend/components/AdminNav.tsx create mode 100644 frontend/components/QRScanner.tsx diff --git a/backend/internal/admin/handler.go b/backend/internal/admin/handler.go index b4b704b..56dc8e3 100644 --- a/backend/internal/admin/handler.go +++ b/backend/internal/admin/handler.go @@ -33,6 +33,12 @@ type transactionsResponse struct { Total int `json:"total"` } +// usersResponse is the JSON body returned by GET /api/v1/admin/users. +type usersResponse struct { + Users []Student `json:"users"` + Total int `json:"total"` +} + // GrantPoints handles POST /api/v1/admin/points/grant. // Accepts {user_id, amount, description}; credits the student's balance. // Requires role=admin. @@ -62,8 +68,7 @@ func (h *Handler) GrantPoints(w http.ResponseWriter, r *http.Request) { // ListTransactions handles GET /api/v1/admin/transactions. // Returns all transactions in the system (paginated), newest first. -// Query params: limit (default 50, max 200), offset (default 0). -// Response: { "transactions": [...], "total": N } +// Query params: limit (default 50, max 200), offset (default 0), type (optional filter). func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { limit := 50 offset := 0 @@ -77,8 +82,9 @@ func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { offset = n } } + txType := r.URL.Query().Get("type") - txs, total, err := h.service.ListTransactions(r.Context(), limit, offset) + txs, total, err := h.service.ListTransactions(r.Context(), limit, offset, txType) if err != nil { slog.Error("handler.ListTransactions", "err", err) response.Error(w, http.StatusInternalServerError, "internal server error") @@ -94,9 +100,24 @@ func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { } // ListUsers handles GET /api/v1/admin/users. -// Returns all students with their current balances. +// Returns all students with balances, sorted by balance desc. +// Query params: search (optional, filters by email/name), limit (default 50), offset (default 0). func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { - students, err := h.service.ListStudents(r.Context()) + search := r.URL.Query().Get("search") + limit := 50 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + students, total, err := h.service.ListStudents(r.Context(), search, limit, offset) if err != nil { slog.Error("handler.ListUsers", "err", err) response.Error(w, http.StatusInternalServerError, "internal server error") @@ -105,7 +126,7 @@ func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { if students == nil { students = []Student{} } - response.JSON(w, http.StatusOK, students) + response.JSON(w, http.StatusOK, usersResponse{Users: students, Total: total}) } // Stats handles GET /api/v1/admin/stats. diff --git a/backend/internal/admin/service.go b/backend/internal/admin/service.go index 0e5ba90..dd12a50 100644 --- a/backend/internal/admin/service.go +++ b/backend/internal/admin/service.go @@ -22,7 +22,6 @@ func NewService(db *pgxpool.Pool, pointsSvc *points.Service) *Service { } // AdminTransaction is a transaction record as seen by an administrator. -// It includes the associated user email for quick identification. type AdminTransaction struct { ID string `json:"id"` UserID string `json:"user_id"` @@ -41,6 +40,7 @@ type Student struct { Name string `json:"name"` StudentID string `json:"student_id,omitempty"` Balance int `json:"balance"` + CreatedAt string `json:"created_at"` } // Stats holds aggregated system metrics shown on the admin dashboard. @@ -64,29 +64,57 @@ func (s *Service) GrantPoints(ctx context.Context, userID string, amount int, de } // ListTransactions returns a paginated slice of all transactions in the system -// (newest first) and the total row count for pagination metadata. -func (s *Service) ListTransactions(ctx context.Context, limit, offset int) ([]AdminTransaction, int, error) { +// (newest first) and the total row count. txType filters by transaction type when non-empty. +func (s *Service) ListTransactions(ctx context.Context, limit, offset int, txType string) ([]AdminTransaction, int, error) { var total int - err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&total) + var err error + + if txType != "" { + err = s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions WHERE type = $1`, txType).Scan(&total) + } else { + err = s.db.QueryRow(ctx, `SELECT COUNT(*) FROM transactions`).Scan(&total) + } if err != nil { return nil, 0, fmt.Errorf("service.ListTransactions: count: %w", err) } - rows, err := s.db.Query(ctx, ` - SELECT t.id, - t.user_id, - u.email, - COALESCE(t.partner_id::text, ''), - t.amount, - t.type, - COALESCE(t.description, ''), - t.created_at - FROM transactions t - JOIN users u ON u.id = t.user_id - ORDER BY t.created_at DESC - LIMIT $1 OFFSET $2`, - limit, offset, - ) + var query string + var args []any + + if txType != "" { + query = ` + SELECT t.id, + t.user_id, + u.email, + COALESCE(t.partner_id::text, ''), + t.amount, + t.type, + COALESCE(t.description, ''), + t.created_at + FROM transactions t + JOIN users u ON u.id = t.user_id + WHERE t.type = $3 + ORDER BY t.created_at DESC + LIMIT $1 OFFSET $2` + args = []any{limit, offset, txType} + } else { + query = ` + SELECT t.id, + t.user_id, + u.email, + COALESCE(t.partner_id::text, ''), + t.amount, + t.type, + COALESCE(t.description, ''), + t.created_at + FROM transactions t + JOIN users u ON u.id = t.user_id + ORDER BY t.created_at DESC + LIMIT $1 OFFSET $2` + args = []any{limit, offset} + } + + rows, err := s.db.Query(ctx, query, args...) if err != nil { return nil, 0, fmt.Errorf("service.ListTransactions: query: %w", err) } @@ -107,31 +135,69 @@ func (s *Service) ListTransactions(ctx context.Context, limit, offset int) ([]Ad return txs, total, nil } -// ListStudents returns all users with role=student, ordered by name. -func (s *Service) ListStudents(ctx context.Context) ([]Student, error) { - rows, err := s.db.Query(ctx, - `SELECT id, email, name, COALESCE(student_id, ''), balance - FROM users - WHERE role = 'student' - ORDER BY name`, - ) +// ListStudents returns students with optional search and pagination. +// search filters by email or name (case-insensitive); empty string returns all. +// Results are sorted by balance DESC when no search term, by name when searching. +func (s *Service) ListStudents(ctx context.Context, search string, limit, offset int) ([]Student, int, error) { + var total int + var err error + + if search != "" { + err = s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM users + WHERE role = 'student' + AND (email ILIKE '%' || $1 || '%' OR name ILIKE '%' || $1 || '%')`, + search, + ).Scan(&total) + } else { + err = s.db.QueryRow(ctx, + `SELECT COUNT(*) FROM users WHERE role = 'student'`, + ).Scan(&total) + } if err != nil { - return nil, fmt.Errorf("service.ListStudents: %w", err) + return nil, 0, fmt.Errorf("service.ListStudents: count: %w", err) + } + + var query string + var args []any + + if search != "" { + query = ` + SELECT id, email, name, COALESCE(student_id, ''), balance, created_at + FROM users + WHERE role = 'student' + AND (email ILIKE '%' || $1 || '%' OR name ILIKE '%' || $1 || '%') + ORDER BY name + LIMIT $2 OFFSET $3` + args = []any{search, limit, offset} + } else { + query = ` + SELECT id, email, name, COALESCE(student_id, ''), balance, created_at + FROM users + WHERE role = 'student' + ORDER BY balance DESC + LIMIT $1 OFFSET $2` + args = []any{limit, offset} + } + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return nil, 0, fmt.Errorf("service.ListStudents: query: %w", err) } defer rows.Close() var students []Student for rows.Next() { var st Student - if err := rows.Scan(&st.ID, &st.Email, &st.Name, &st.StudentID, &st.Balance); err != nil { - return nil, fmt.Errorf("service.ListStudents: scan: %w", err) + if err := rows.Scan(&st.ID, &st.Email, &st.Name, &st.StudentID, &st.Balance, &st.CreatedAt); err != nil { + return nil, 0, fmt.Errorf("service.ListStudents: scan: %w", err) } students = append(students, st) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("service.ListStudents: rows: %w", err) + return nil, 0, fmt.Errorf("service.ListStudents: rows: %w", err) } - return students, nil + return students, total, nil } // GetStats returns aggregated system statistics for the admin dashboard. diff --git a/backend/internal/auth/handler_test.go b/backend/internal/auth/handler_test.go new file mode 100644 index 0000000..052a7b2 --- /dev/null +++ b/backend/internal/auth/handler_test.go @@ -0,0 +1,187 @@ +package auth_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/cu-points/backend/internal/auth" +) + +func newTestHandler(repo auth.UserRepository) *auth.Handler { + jwtMgr := auth.NewJWTManager( + "test-secret-minimum-32-characters-long", + 15*time.Minute, + 168*time.Hour, + ) + svc := auth.NewService(repo, jwtMgr) + return auth.NewHandler(svc) +} + +// ─── Login ─────────────────────────────────────────────────────────────────── + +func TestHandler_Login_Success(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("pass123"), bcrypt.MinCost) + repo := &mockRepo{ + user: &auth.UserRecord{ + ID: "u-1", + Email: "a@cu.ru", + PasswordHash: string(hash), + Role: "student", + }, + } + h := newTestHandler(repo) + + body, _ := json.Marshal(map[string]string{"email": "a@cu.ru", "password": "pass123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Login(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp struct { + Data struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Data.AccessToken == "" || resp.Data.RefreshToken == "" { + t.Error("expected both tokens to be non-empty") + } +} + +func TestHandler_Login_InvalidJSON(t *testing.T) { + h := newTestHandler(&mockRepo{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", + strings.NewReader("{bad json")) + w := httptest.NewRecorder() + + h.Login(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_Login_MissingFields(t *testing.T) { + h := newTestHandler(&mockRepo{}) + + body, _ := json.Marshal(map[string]string{"email": "", "password": ""}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Login(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_Login_WrongPassword(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost) + repo := &mockRepo{ + user: &auth.UserRecord{ + ID: "u-1", + Email: "a@cu.ru", + PasswordHash: string(hash), + Role: "student", + }, + } + h := newTestHandler(repo) + + body, _ := json.Marshal(map[string]string{"email": "a@cu.ru", "password": "wrong"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Login(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +// ─── Refresh ───────────────────────────────────────────────────────────────── + +func TestHandler_Refresh_Success(t *testing.T) { + jwtMgr := auth.NewJWTManager( + "test-secret-minimum-32-characters-long", + 15*time.Minute, + 168*time.Hour, + ) + user := &auth.UserRecord{ID: "u-2", Role: "student"} + repo := &mockRepo{user: user} + svc := auth.NewService(repo, jwtMgr) + h := auth.NewHandler(svc) + + refreshToken, _ := jwtMgr.GenerateRefreshToken(user.ID) + + body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Refresh(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandler_Refresh_InvalidJSON(t *testing.T) { + h := newTestHandler(&mockRepo{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", + strings.NewReader("{bad json")) + w := httptest.NewRecorder() + + h.Refresh(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_Refresh_MissingToken(t *testing.T) { + h := newTestHandler(&mockRepo{}) + + body, _ := json.Marshal(map[string]string{"refresh_token": ""}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Refresh(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandler_Refresh_InvalidToken(t *testing.T) { + h := newTestHandler(&mockRepo{}) + + body, _ := json.Marshal(map[string]string{"refresh_token": "bad.token.here"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.Refresh(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go index bcc3c0d..9bf1a36 100644 --- a/backend/internal/auth/service_test.go +++ b/backend/internal/auth/service_test.go @@ -46,6 +46,17 @@ func newTestService(repo auth.UserRepository) *auth.Service { return auth.NewService(repo, jwtMgr) } +// newTestJWT returns a JWTManager configured with the test secret. +func newTestJWT() *auth.JWTManager { + return auth.NewJWTManager( + "test-secret-minimum-32-characters-long", + 15*time.Minute, + 168*time.Hour, + ) +} + +// ─── Login ──────────────────────────────────────────────────────────────────── + func TestService_Login_Success(t *testing.T) { repo := &mockRepo{ user: &auth.UserRecord{ @@ -107,3 +118,185 @@ func TestService_Login_UserNotFound(t *testing.T) { t.Errorf("expected ErrInvalidCredentials, got: %v", err) } } + +func TestService_Login_RepoError(t *testing.T) { + repo := &mockRepo{repoErr: errors.New("db error")} + svc := newTestService(repo) + + _, err := svc.Login(context.Background(), auth.LoginRequest{ + Email: "user@cu.ru", + Password: "pass", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + // Must NOT be ErrInvalidCredentials — we don't want to mask infra errors. + if errors.Is(err, auth.ErrInvalidCredentials) { + t.Error("unexpected ErrInvalidCredentials for non-ErrNotFound repo error") + } +} + +// ─── Refresh ───────────────────────────────────────────────────────────────── + +func TestService_Refresh_Success(t *testing.T) { + jwtMgr := newTestJWT() + user := &auth.UserRecord{ + ID: "user-1", + Role: "student", + } + repo := &mockRepo{user: user} + svc := auth.NewService(repo, jwtMgr) + + // Generate a real refresh token via the JWT manager. + refreshToken, err := jwtMgr.GenerateRefreshToken(user.ID) + if err != nil { + t.Fatalf("generate refresh token: %v", err) + } + + accessToken, err := svc.Refresh(context.Background(), refreshToken) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if accessToken == "" { + t.Error("expected non-empty access token") + } +} + +func TestService_Refresh_InvalidToken(t *testing.T) { + svc := newTestService(&mockRepo{}) + + _, err := svc.Refresh(context.Background(), "not.a.valid.token") + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Errorf("expected ErrInvalidCredentials, got: %v", err) + } +} + +func TestService_Refresh_WrongTokenType(t *testing.T) { + jwtMgr := newTestJWT() + svc := auth.NewService(&mockRepo{}, jwtMgr) + + // Use an access token where a refresh token is expected. + accessToken, _ := jwtMgr.GenerateAccessToken("user-1", "student") + + _, err := svc.Refresh(context.Background(), accessToken) + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Errorf("expected ErrInvalidCredentials, got: %v", err) + } +} + +func TestService_Refresh_UserNotFound(t *testing.T) { + jwtMgr := newTestJWT() + repo := &mockRepo{repoErr: auth.ErrNotFound} + svc := auth.NewService(repo, jwtMgr) + + refreshToken, _ := jwtMgr.GenerateRefreshToken("deleted-user") + + _, err := svc.Refresh(context.Background(), refreshToken) + if !errors.Is(err, auth.ErrInvalidCredentials) { + t.Errorf("expected ErrInvalidCredentials, got: %v", err) + } +} + +// ─── ValidateToken ──────────────────────────────────────────────────────────── + +func TestService_ValidateToken_Success(t *testing.T) { + jwtMgr := newTestJWT() + svc := auth.NewService(&mockRepo{}, jwtMgr) + + accessToken, _ := jwtMgr.GenerateAccessToken("user-1", "student") + + claims, err := svc.ValidateToken(accessToken) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if claims.Subject != "user-1" { + t.Errorf("expected subject=user-1, got %s", claims.Subject) + } +} + +func TestService_ValidateToken_InvalidToken(t *testing.T) { + svc := newTestService(&mockRepo{}) + + _, err := svc.ValidateToken("garbage.token.value") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestService_ValidateToken_RefreshTokenRejected(t *testing.T) { + jwtMgr := newTestJWT() + svc := auth.NewService(&mockRepo{}, jwtMgr) + + refreshToken, _ := jwtMgr.GenerateRefreshToken("user-1") + + _, err := svc.ValidateToken(refreshToken) + if err == nil { + t.Fatal("expected error for refresh token passed to ValidateToken") + } +} + +// ─── JWT round-trip ─────────────────────────────────────────────────────────── + +func TestJWTManager_AccessToken_RoundTrip(t *testing.T) { + mgr := newTestJWT() + + token, err := mgr.GenerateAccessToken("user-42", "admin") + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := mgr.ParseToken(token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.Subject != "user-42" { + t.Errorf("subject: want user-42, got %s", claims.Subject) + } + if claims.Role != "admin" { + t.Errorf("role: want admin, got %s", claims.Role) + } + if claims.Type != "access" { + t.Errorf("type: want access, got %s", claims.Type) + } +} + +func TestJWTManager_RefreshToken_RoundTrip(t *testing.T) { + mgr := newTestJWT() + + token, err := mgr.GenerateRefreshToken("user-7") + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := mgr.ParseToken(token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.Subject != "user-7" { + t.Errorf("subject: want user-7, got %s", claims.Subject) + } + if claims.Type != "refresh" { + t.Errorf("type: want refresh, got %s", claims.Type) + } +} + +func TestJWTManager_ParseToken_Invalid(t *testing.T) { + mgr := newTestJWT() + + _, err := mgr.ParseToken("not.a.valid.jwt") + if err == nil { + t.Fatal("expected error for invalid JWT, got nil") + } +} + +func TestJWTManager_ParseToken_WrongSecret(t *testing.T) { + mgr1 := newTestJWT() + mgr2 := auth.NewJWTManager("other-secret-that-is-at-least-32-chars-long", 15*time.Minute, 168*time.Hour) + + token, _ := mgr1.GenerateAccessToken("user-1", "student") + + _, err := mgr2.ParseToken(token) + if err == nil { + t.Fatal("expected error when parsing with wrong secret") + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 57d20d2..b5b7f93 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -34,6 +34,12 @@ func UserIDFromContext(ctx context.Context) string { return v } +// ContextWithUserID returns a copy of ctx carrying the given userID. +// Intended only for handler unit tests that bypass Auth middleware. +func ContextWithUserID(ctx context.Context, userID string) context.Context { + return context.WithValue(ctx, userIDKey, userID) +} + // UserRoleFromContext retrieves the authenticated user's role stored by Auth middleware. func UserRoleFromContext(ctx context.Context) string { v, _ := ctx.Value(userRoleKey).(string) diff --git a/backend/internal/points/handler.go b/backend/internal/points/handler.go index 1ef7658..60a6949 100644 --- a/backend/internal/points/handler.go +++ b/backend/internal/points/handler.go @@ -32,6 +32,13 @@ type spendRequest struct { Amount int `json:"amount"` } +// spendResponse is returned on a successful spend; includes new balance for the partner UI. +type spendResponse struct { + Status string `json:"status"` + Spent int `json:"spent"` + NewBalance int `json:"new_balance"` +} + // 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) { @@ -67,7 +74,7 @@ func (h *Handler) Spend(w http.ResponseWriter, r *http.Request) { partnerID := middleware.UserIDFromContext(r.Context()) - err := h.service.SpendPoints(r.Context(), SpendRequest{ + newBalance, err := h.service.SpendPoints(r.Context(), SpendRequest{ QRToken: req.QRToken, Amount: req.Amount, PartnerID: partnerID, @@ -87,5 +94,5 @@ func (h *Handler) Spend(w http.ResponseWriter, r *http.Request) { return } - response.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) + response.JSON(w, http.StatusOK, spendResponse{Status: "ok", Spent: req.Amount, NewBalance: newBalance}) } diff --git a/backend/internal/points/handler_test.go b/backend/internal/points/handler_test.go new file mode 100644 index 0000000..3cc82ff --- /dev/null +++ b/backend/internal/points/handler_test.go @@ -0,0 +1,218 @@ +package points_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cu-points/backend/internal/middleware" + "github.com/cu-points/backend/internal/points" +) + +// injectUserID puts a user_id into the request context the same way middleware.Auth does. +func injectUserID(r *http.Request, userID string) *http.Request { + return r.WithContext(middleware.ContextWithUserID(r.Context(), userID)) +} + +func newHandlerWithService(repo points.Repository, cache points.CacheClient) *points.Handler { + svc := points.NewService(repo, cache, testSecret) + return points.NewHandler(svc) +} + +// ─── GenerateQR ─────────────────────────────────────────────────────────────── + +func TestGenerateQR_Success(t *testing.T) { + h := newHandlerWithService(&mockRepo{}, &mockCache{}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/me/qr", nil) + req = injectUserID(req, "user-1") + w := httptest.NewRecorder() + + h.GenerateQR(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var envelope struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if envelope.Data.Token == "" { + t.Error("expected non-empty token in response") + } +} + +// ─── Spend ─────────────────────────────────────────────────────────────────── + +func TestSpend_Success(t *testing.T) { + svc := points.NewService( + &mockRepo{balance: 500, newBalance: 400}, + &mockCache{}, + testSecret, + ) + // Generate a valid token first. + token, _ := svc.GenerateQRToken(context.Background(), "student-1") + + h := points.NewHandler(svc) + + body, _ := json.Marshal(map[string]interface{}{ + "qr_token": token, + "amount": 100, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSpend_InvalidJSON(t *testing.T) { + h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", + strings.NewReader("{bad json")) + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestSpend_MissingQRToken(t *testing.T) { + h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{}) + + body, _ := json.Marshal(map[string]interface{}{"amount": 100}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestSpend_ZeroAmount(t *testing.T) { + h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{}) + + body, _ := json.Marshal(map[string]interface{}{"qr_token": "sometoken", "amount": 0}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestSpend_InvalidToken_Returns401(t *testing.T) { + h := newHandlerWithService(&mockRepo{balance: 500}, &mockCache{}) + + body, _ := json.Marshal(map[string]interface{}{"qr_token": "bad.token.here", "amount": 100}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } +} + +func TestSpend_AlreadyUsedToken_Returns409(t *testing.T) { + svc := points.NewService( + &mockRepo{balance: 500}, + &mockCache{used: true}, + testSecret, + ) + token, _ := svc.GenerateQRToken(context.Background(), "student-1") + h := points.NewHandler(svc) + + body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} + +func TestSpend_InternalError_Returns500(t *testing.T) { + // Trigger the default error path by making the cache return a generic error. + svc := points.NewService( + &mockRepo{balance: 500}, + &mockCache{isErr: errors.New("redis timeout")}, + testSecret, + ) + token, _ := svc.GenerateQRToken(context.Background(), "student-1") + // Rebuild the service with the broken cache for the actual spend call. + svc2 := points.NewService( + &mockRepo{balance: 500}, + &mockCache{isErr: errors.New("redis timeout")}, + testSecret, + ) + h := points.NewHandler(svc2) + + body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } +} + +func TestSpend_InsufficientBalance_Returns422(t *testing.T) { + svc := points.NewService( + &mockRepo{balance: 10}, + &mockCache{}, + testSecret, + ) + token, _ := svc.GenerateQRToken(context.Background(), "student-1") + h := points.NewHandler(svc) + + body, _ := json.Marshal(map[string]interface{}{"qr_token": token, "amount": 100}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/partner/spend", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = injectUserID(req, "partner-1") + w := httptest.NewRecorder() + + h.Spend(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d", w.Code) + } +} diff --git a/backend/internal/points/repository.go b/backend/internal/points/repository.go index c9cfa90..c61d2d7 100644 --- a/backend/internal/points/repository.go +++ b/backend/internal/points/repository.go @@ -18,9 +18,10 @@ type Repository interface { // 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 + // in a single database transaction. Returns the new balance on success. + // 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) (int, error) } // CacheClient defines the Redis operations needed by the points service. @@ -91,10 +92,10 @@ func (r *pgRepository) EarnAtomic(ctx context.Context, userID string, amount int // 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 { +func (r *pgRepository) SpendAtomic(ctx context.Context, userID, partnerID string, amount int) (int, error) { tx, err := r.db.Begin(ctx) if err != nil { - return fmt.Errorf("repository.SpendAtomic: begin: %w", err) + return 0, fmt.Errorf("repository.SpendAtomic: begin: %w", err) } defer tx.Rollback(ctx) //nolint:errcheck @@ -104,7 +105,7 @@ func (r *pgRepository) SpendAtomic(ctx context.Context, userID, partnerID string amount, userID, ).Scan(&newBalance) if err != nil { - return fmt.Errorf("repository.SpendAtomic: update balance: %w", err) + return 0, fmt.Errorf("repository.SpendAtomic: update balance: %w", err) } _, err = tx.Exec(ctx, @@ -113,13 +114,13 @@ func (r *pgRepository) SpendAtomic(ctx context.Context, userID, partnerID string userID, partnerID, -amount, ) if err != nil { - return fmt.Errorf("repository.SpendAtomic: insert transaction: %w", err) + return 0, fmt.Errorf("repository.SpendAtomic: insert transaction: %w", err) } if err := tx.Commit(ctx); err != nil { - return fmt.Errorf("repository.SpendAtomic: commit: %w", err) + return 0, fmt.Errorf("repository.SpendAtomic: commit: %w", err) } - return nil + return newBalance, nil } // redisCache is the Redis-backed implementation of CacheClient. diff --git a/backend/internal/points/repository_test.go b/backend/internal/points/repository_test.go new file mode 100644 index 0000000..f9dd29a --- /dev/null +++ b/backend/internal/points/repository_test.go @@ -0,0 +1,31 @@ +package points_test + +import ( + "testing" + "time" + + "github.com/cu-points/backend/internal/points" + "github.com/redis/go-redis/v9" +) + +// TestNewRepository_Constructor exercises the factory function without a real DB. +// Method calls on the returned value would panic (nil pool), so we only test creation. +func TestNewRepository_Constructor(t *testing.T) { + repo := points.NewRepository(nil) + if repo == nil { + t.Error("NewRepository(nil) returned nil") + } +} + +// TestNewRedisCache_Constructor exercises the cache factory without a real Redis. +func TestNewRedisCache_Constructor(t *testing.T) { + // Use a client with an unreachable address; we only test that construction succeeds. + client := redis.NewClient(&redis.Options{ + Addr: "localhost:0", + DialTimeout: time.Millisecond, + }) + cache := points.NewRedisCache(client) + if cache == nil { + t.Error("NewRedisCache returned nil") + } +} diff --git a/backend/internal/points/service.go b/backend/internal/points/service.go index 5e217b3..6847e76 100644 --- a/backend/internal/points/service.go +++ b/backend/internal/points/service.go @@ -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. diff --git a/backend/internal/points/service_test.go b/backend/internal/points/service_test.go new file mode 100644 index 0000000..bbc6208 --- /dev/null +++ b/backend/internal/points/service_test.go @@ -0,0 +1,432 @@ +package points_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "errors" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/cu-points/backend/internal/points" +) + +// ─── mocks ─────────────────────────────────────────────────────────────────── + +type mockRepo struct { + balance int + balanceErr error + earnErr error + spendErr error + newBalance int +} + +func (m *mockRepo) GetBalance(_ context.Context, _ string) (int, error) { + return m.balance, m.balanceErr +} +func (m *mockRepo) EarnAtomic(_ context.Context, _ string, _ int, _, _ string) error { + return m.earnErr +} +func (m *mockRepo) SpendAtomic(_ context.Context, _, _ string, _ int) (int, error) { + return m.newBalance, m.spendErr +} + +type mockCache struct { + used bool + isErr error + markErr error + markedID string +} + +func (m *mockCache) IsQRUsed(_ context.Context, _ string) (bool, error) { + return m.used, m.isErr +} +func (m *mockCache) MarkQRUsed(_ context.Context, jti string) error { + m.markedID = jti + return m.markErr +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +const testSecret = "test-secret-minimum-32-characters-long" + +func newService(repo points.Repository, cache points.CacheClient) *points.Service { + return points.NewService(repo, cache, testSecret) +} + +// makeExpiredToken creates a QR JWT that is already past its expiry. +func makeExpiredToken(secret string, userID string) string { + type qrClaims struct { + jwt.RegisteredClaims + Type string `json:"type"` + } + claims := qrClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + ID: "expired-jti", + IssuedAt: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Second)), + }, + Type: "qr", + } + tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + return tok +} + +// makeWrongTypeToken creates a valid JWT but with type != "qr". +func makeWrongTypeToken(secret string, userID string) string { + type qrClaims struct { + jwt.RegisteredClaims + Type string `json:"type"` + } + claims := qrClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + ID: "wrong-type-jti", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), + }, + Type: "access", // wrong + } + tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + return tok +} + +// ─── EarnPoints tests ───────────────────────────────────────────────────────── + +func TestEarnPoints_Success(t *testing.T) { + repo := &mockRepo{} + svc := newService(repo, &mockCache{}) + + err := svc.EarnPoints(context.Background(), points.EarnRequest{ + UserID: "user-1", + Amount: 100, + Type: "earn", + Description: "test", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } +} + +func TestEarnPoints_ZeroAmount(t *testing.T) { + svc := newService(&mockRepo{}, &mockCache{}) + + err := svc.EarnPoints(context.Background(), points.EarnRequest{ + UserID: "user-1", + Amount: 0, + Type: "earn", + }) + if err == nil { + t.Fatal("expected error for zero amount, got nil") + } +} + +func TestEarnPoints_NegativeAmount(t *testing.T) { + svc := newService(&mockRepo{}, &mockCache{}) + + err := svc.EarnPoints(context.Background(), points.EarnRequest{ + UserID: "user-1", + Amount: -50, + Type: "earn", + }) + if err == nil { + t.Fatal("expected error for negative amount, got nil") + } +} + +func TestEarnPoints_RepoError(t *testing.T) { + repoErr := errors.New("db is down") + repo := &mockRepo{earnErr: repoErr} + svc := newService(repo, &mockCache{}) + + err := svc.EarnPoints(context.Background(), points.EarnRequest{ + UserID: "user-1", + Amount: 100, + Type: "earn", + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ─── GenerateQRToken tests ──────────────────────────────────────────────────── + +func TestGenerateQRToken_ReturnsToken(t *testing.T) { + svc := newService(&mockRepo{}, &mockCache{}) + + token, err := svc.GenerateQRToken(context.Background(), "user-1") + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if token == "" { + t.Fatal("expected non-empty token") + } +} + +func TestGenerateQRToken_DifferentEachCall(t *testing.T) { + svc := newService(&mockRepo{}, &mockCache{}) + + t1, _ := svc.GenerateQRToken(context.Background(), "user-1") + t2, _ := svc.GenerateQRToken(context.Background(), "user-1") + if t1 == t2 { + t.Error("expected different tokens on successive calls (distinct jti)") + } +} + +// ─── SpendPoints tests ──────────────────────────────────────────────────────── + +func TestSpendPoints_Success(t *testing.T) { + svc := newService( + &mockRepo{balance: 500, newBalance: 400}, + &mockCache{}, + ) + + token, err := svc.GenerateQRToken(context.Background(), "user-1") + if err != nil { + t.Fatalf("generate token: %v", err) + } + + newBalance, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if newBalance != 400 { + t.Errorf("expected newBalance=400, got %d", newBalance) + } +} + +func TestSpendPoints_InsufficientBalance(t *testing.T) { + svc := newService( + &mockRepo{balance: 50}, + &mockCache{}, + ) + + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInsufficientBalance) { + t.Errorf("expected ErrInsufficientBalance, got: %v", err) + } +} + +func TestSpendPoints_QRAlreadyUsed(t *testing.T) { + svc := newService( + &mockRepo{balance: 500}, + &mockCache{used: true}, + ) + + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrQRAlreadyUsed) { + t.Errorf("expected ErrQRAlreadyUsed, got: %v", err) + } +} + +func TestSpendPoints_InvalidToken(t *testing.T) { + svc := newService(&mockRepo{balance: 500}, &mockCache{}) + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: "not.a.valid.jwt", + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInvalidQRToken) { + t.Errorf("expected ErrInvalidQRToken, got: %v", err) + } +} + +func TestSpendPoints_ExpiredToken(t *testing.T) { + svc := newService(&mockRepo{balance: 500}, &mockCache{}) + + expiredToken := makeExpiredToken(testSecret, "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: expiredToken, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInvalidQRToken) { + t.Errorf("expected ErrInvalidQRToken for expired token, got: %v", err) + } +} + +func TestSpendPoints_WrongSigningMethod(t *testing.T) { + // Build a QR token signed with RS256 to trigger the "unexpected signing method" check. + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + type qrClaims struct { + jwt.RegisteredClaims + Type string `json:"type"` + } + claims := qrClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-1", + ID: "jti-1", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), + }, + Type: "qr", + } + rs256Token, _ := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(rsaKey) + + svc := newService(&mockRepo{balance: 500}, &mockCache{}) + + _, err = svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: rs256Token, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInvalidQRToken) { + t.Errorf("expected ErrInvalidQRToken for RS256-signed token, got: %v", err) + } +} + +func TestSpendPoints_WrongTokenType(t *testing.T) { + svc := newService(&mockRepo{balance: 500}, &mockCache{}) + + wrongToken := makeWrongTypeToken(testSecret, "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: wrongToken, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInvalidQRToken) { + t.Errorf("expected ErrInvalidQRToken for wrong type token, got: %v", err) + } +} + +func TestSpendPoints_MarksTokenUsedAfterSuccess(t *testing.T) { + cache := &mockCache{} + svc := newService( + &mockRepo{balance: 500, newBalance: 400}, + cache, + ) + + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if cache.markedID == "" { + t.Error("expected MarkQRUsed to be called after successful spend") + } +} + +func TestSpendPoints_CacheCheckError(t *testing.T) { + svc := newService( + &mockRepo{balance: 500}, + &mockCache{isErr: errors.New("redis down")}, + ) + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err == nil { + t.Fatal("expected error from cache check, got nil") + } + // Should NOT be a domain-level error — it's an infrastructure error. + if errors.Is(err, points.ErrInsufficientBalance) || errors.Is(err, points.ErrQRAlreadyUsed) { + t.Errorf("unexpected domain error for cache failure: %v", err) + } +} + +func TestSpendPoints_GetBalanceError(t *testing.T) { + svc := newService( + &mockRepo{balanceErr: errors.New("db error"), balance: 0}, + &mockCache{}, + ) + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err == nil { + t.Fatal("expected error from GetBalance, got nil") + } +} + +func TestSpendPoints_SpendAtomicNonConstraintError(t *testing.T) { + svc := newService( + &mockRepo{balance: 500, spendErr: errors.New("connection reset")}, + &mockCache{}, + ) + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err == nil { + t.Fatal("expected error from SpendAtomic, got nil") + } + if errors.Is(err, points.ErrInsufficientBalance) { + t.Error("non-constraint error should not map to ErrInsufficientBalance") + } +} + +func TestSpendPoints_MarkQRUsedError(t *testing.T) { + svc := newService( + &mockRepo{balance: 500, newBalance: 400}, + &mockCache{markErr: errors.New("redis write failed")}, + ) + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if err == nil { + t.Fatal("expected error from MarkQRUsed, got nil") + } +} + +func TestSpendPoints_DbConstraintError_ReturnsInsufficientBalance(t *testing.T) { + // Simulate a scenario where the pre-check passes (balance == amount) + // but the DB CHECK constraint fires (e.g. concurrent spend). + svc := newService( + &mockRepo{balance: 100, spendErr: errors.New("check constraint violation")}, + &mockCache{}, + ) + + token, _ := svc.GenerateQRToken(context.Background(), "user-1") + + _, err := svc.SpendPoints(context.Background(), points.SpendRequest{ + QRToken: token, + Amount: 100, + PartnerID: "partner-1", + }) + if !errors.Is(err, points.ErrInsufficientBalance) { + t.Errorf("expected ErrInsufficientBalance from constraint error, got: %v", err) + } +} diff --git a/frontend/app/(partner)/scan/page.tsx b/frontend/app/(partner)/scan/page.tsx index b049b58..ea3ccb3 100644 --- a/frontend/app/(partner)/scan/page.tsx +++ b/frontend/app/(partner)/scan/page.tsx @@ -1,8 +1,203 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { QRScanner } from '@/components/QRScanner'; +import { Button, Card, Input } from '@/components/ui'; +import { api } from '@/lib/api'; +import { formatPoints } from '@/lib/utils'; +import { useAuthStore } from '@/lib/store'; +import type { SpendResult } from '@/lib/types'; + +type Step = 'scan' | 'form' | 'result'; + +// Default cap: partner can spend at most 50% of the purchase total in points. +// The backend validates against the actual partner's max_spend_pct. +const DEFAULT_MAX_SPEND_PCT = 0.5; + export default function ScanPage() { - // TODO: QR scanner + amount input form → POST /api/v1/partner/spend → show result + const { user, logout } = useAuthStore(); + const [step, setStep] = useState('scan'); + const [qrToken, setQrToken] = useState(''); + const [purchaseTotal, setPurchaseTotal] = useState(''); + const [pointsToSpend, setPointsToSpend] = useState(''); + const [result, setResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(''); + const [loading, setLoading] = useState(false); + + const handleScan = useCallback((token: string) => { + setQrToken(token); + setStep('form'); + }, []); + + function handlePurchaseTotalChange(val: string) { + setPurchaseTotal(val); + const total = parseFloat(val); + if (!isNaN(total) && total > 0) { + setPointsToSpend(String(Math.floor(total * DEFAULT_MAX_SPEND_PCT))); + } else { + setPointsToSpend(''); + } + } + + function handlePointsChange(val: string) { + const total = parseFloat(purchaseTotal); + const max = Math.floor(total * DEFAULT_MAX_SPEND_PCT); + const entered = parseInt(val, 10); + // Prevent partner from setting points above the allowed cap. + if (!isNaN(entered) && !isNaN(max) && entered > max) { + setPointsToSpend(String(max)); + } else { + setPointsToSpend(val); + } + } + + const totalNum = parseFloat(purchaseTotal) || 0; + const pointsNum = parseInt(pointsToSpend, 10) || 0; + const remainder = Math.max(0, totalNum - pointsNum); + + async function handleSubmit() { + if (pointsNum <= 0) return; + setLoading(true); + setErrorMsg(''); + try { + const data = await api.post('/api/v1/partner/spend', { + qr_token: qrToken, + amount: pointsNum, + }); + setResult(data); + setStep('result'); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Ошибка'; + if (msg.includes('insufficient')) { + setErrorMsg('Недостаточно поинтов на балансе студента.'); + } else if (msg.includes('already been used')) { + setErrorMsg('Этот QR-код уже был использован. Попросите студента создать новый.'); + } else if (msg.includes('invalid or expired') || msg.includes('expired')) { + setErrorMsg('QR-код недействителен или истёк. Попросите студента создать новый QR.'); + } else { + setErrorMsg(msg); + } + setStep('result'); + } finally { + setLoading(false); + } + } + + function reset() { + setStep('scan'); + setQrToken(''); + setPurchaseTotal(''); + setPointsToSpend(''); + setResult(null); + setErrorMsg(''); + } + return ( -
-

Partner scan — coming soon

-
+
+ {/* Partner header */} +
+
+ + CU Points — {user?.name ?? 'Партнёр'} + + +
+
+ +
+

Оплата поинтами

+ + {/* ── Step 1: QR scan ─────────────────────────────── */} + {step === 'scan' && ( +
+

Наведи камеру на QR студента

+ +
+ )} + + {/* ── Step 2: Amount entry ─────────────────────────── */} + {step === 'form' && ( + +
+ +

Студент отсканирован

+
+ + handlePurchaseTotalChange(e.target.value)} + placeholder="0" + autoFocus + /> + + handlePointsChange(e.target.value)} + placeholder="0" + /> + + {totalNum > 0 && ( +

+ Остаток к оплате:{' '} + {remainder.toFixed(0)} ₽ +

+ )} + + + +
+ )} + + {/* ── Step 3: Result ───────────────────────────────── */} + {step === 'result' && ( + + {result ? ( + <> +

+

+ Списано {result.spent} поинтов +

+

+ Новый баланс студента:{' '} + + {formatPoints(result.new_balance)} + +

+ + ) : ( + <> +

+

{errorMsg || 'Неизвестная ошибка'}

+ + )} + +
+ )} +
+
); } diff --git a/frontend/app/admin/dashboard/page.tsx b/frontend/app/admin/dashboard/page.tsx index 0d58947..f02e206 100644 --- a/frontend/app/admin/dashboard/page.tsx +++ b/frontend/app/admin/dashboard/page.tsx @@ -1,8 +1,130 @@ -export default function AdminDashboardPage() { - // TODO(notion): fetch GET /api/v1/admin/stats → display key metrics + recent transactions +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { AdminNav } from '@/components/AdminNav'; +import { Badge, Card, Spinner } from '@/components/ui'; +import { api } from '@/lib/api'; +import { formatPoints, formatDate, formatTransactionAmount } from '@/lib/utils'; +import type { Stats, AdminTransactionPage, AdminTransaction } from '@/lib/types'; + +interface StatCardProps { + label: string; + value: string | number; +} + +function StatCard({ label, value }: StatCardProps) { return ( -
-

Admin dashboard — coming soon

-
+ +

{label}

+

{value}

+
+ ); +} + +export default function AdminDashboardPage() { + const [stats, setStats] = useState(null); + const [recentTxs, setRecentTxs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function load() { + try { + const [s, page] = await Promise.all([ + api.get('/api/v1/admin/stats'), + api.get('/api/v1/admin/transactions?limit=10'), + ]); + setStats(s); + setRecentTxs(page.transactions); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка загрузки'); + } finally { + setLoading(false); + } + } + load(); + }, []); + + return ( +
+ +
+

Дашборд

+ + {loading && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} + + {!loading && stats && ( +
+ + + + +
+ )} + + {!loading && ( +
+
+

Последние транзакции

+ + Все транзакции → + +
+ + + {recentTxs.length === 0 ? ( +

Транзакций ещё нет

+ ) : ( + + + + + + + + + + + {recentTxs.map((tx) => ( + + + + + + + ))} + +
СтудентТипСуммаДата
{tx.user_email} + + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {formatTransactionAmount(tx.amount)} + + {formatDate(tx.created_at)} +
+ )} +
+ +
+ + Начислить поинты → + +
+
+ )} +
+
); } diff --git a/frontend/app/admin/grant/page.tsx b/frontend/app/admin/grant/page.tsx index 6e6936d..688c66a 100644 --- a/frontend/app/admin/grant/page.tsx +++ b/frontend/app/admin/grant/page.tsx @@ -1,8 +1,250 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { AdminNav } from '@/components/AdminNav'; +import { Button, Card, Input, Spinner } from '@/components/ui'; +import { api } from '@/lib/api'; +import { formatPoints, formatDate } from '@/lib/utils'; +import type { AdminStudent, AdminUsersPage } from '@/lib/types'; + +interface GrantRecord { + key: string; + studentName: string; + amount: number; + description: string; + grantedAt: string; +} + export default function GrantPage() { - // TODO(notion): form (user_id, amount, description) → POST /api/v1/admin/points/grant → confirmation + const [search, setSearch] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [selected, setSelected] = useState(null); + const [showDropdown, setShowDropdown] = useState(false); + const [amount, setAmount] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(''); + const [history, setHistory] = useState([]); + const dropdownRef = useRef(null); + + // Debounced search: fires 300 ms after the user stops typing. + useEffect(() => { + if (!search.trim() || selected) { + setSuggestions([]); + setShowDropdown(false); + return; + } + setSearching(true); + const timer = setTimeout(async () => { + try { + const page = await api.get( + `/api/v1/admin/users?search=${encodeURIComponent(search)}&limit=8`, + ); + setSuggestions(page.users); + setShowDropdown(page.users.length > 0); + } catch { + setSuggestions([]); + } finally { + setSearching(false); + } + }, 300); + return () => clearTimeout(timer); + }, [search, selected]); + + // Close dropdown when clicking outside. + useEffect(() => { + function handler(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowDropdown(false); + } + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + function selectStudent(student: AdminStudent) { + setSelected(student); + setSearch(student.name + ' — ' + student.email); + setSuggestions([]); + setShowDropdown(false); + } + + function clearSelection() { + setSelected(null); + setSearch(''); + setSuggestions([]); + } + + async function handleGrant() { + if (!selected || !amount) return; + const pts = parseInt(amount, 10); + if (isNaN(pts) || pts <= 0) return; + + setLoading(true); + setError(''); + try { + await api.post<{ status: string }>('/api/v1/admin/points/grant', { + user_id: selected.id, + amount: pts, + description, + }); + + // Refresh selected student balance. + const page = await api.get( + `/api/v1/admin/users?search=${encodeURIComponent(selected.email)}&limit=1`, + ); + const updated = page.users.find((s) => s.id === selected.id); + if (updated) setSelected(updated); + + setHistory((prev) => + [ + { + key: String(Date.now()), + studentName: selected.name, + amount: pts, + description: description || '—', + grantedAt: new Date().toISOString(), + }, + ...prev, + ].slice(0, 5), + ); + setAmount(''); + setDescription(''); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка начисления'); + } finally { + setLoading(false); + } + } + + const amountNum = parseInt(amount, 10); + const canSubmit = !!selected && amountNum > 0 && !loading; + return ( -
-

Grant points — coming soon

-
+
+ +
+

Начислить поинты

+ + + {/* Student search */} +
+
+
+ { + setSearch(e.target.value); + if (selected) clearSelection(); + }} + placeholder="student@cu.ru" + autoComplete="off" + /> +
+ {selected && ( + + )} +
+ + {/* Suggestions dropdown */} + {showDropdown && suggestions.length > 0 && ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ )} + + {searching && !showDropdown && ( +
+ +
+ )} +
+ + {/* Selected student info */} + {selected && ( +
+

{selected.name}

+

{selected.email}

+

+ Текущий баланс: {formatPoints(selected.balance)} +

+
+ )} + + setAmount(e.target.value)} + placeholder="100" + /> + + setDescription(e.target.value)} + placeholder="Победа в хакатоне" + /> + + {error && ( +

{error}

+ )} + + +
+ + {/* Recent grants in this session */} + {history.length > 0 && ( +
+

+ Последние начисления (сессия) +

+ +
    + {history.map((h) => ( +
  • +
    +

    {h.studentName}

    +

    {h.description}

    +
    +
    +

    +{h.amount}

    +

    {formatDate(h.grantedAt)}

    +
    +
  • + ))} +
+
+
+ )} +
+
); } diff --git a/frontend/app/admin/transactions/page.tsx b/frontend/app/admin/transactions/page.tsx new file mode 100644 index 0000000..38c8fe5 --- /dev/null +++ b/frontend/app/admin/transactions/page.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { AdminNav } from '@/components/AdminNav'; +import { Badge, Button, Card, Spinner } from '@/components/ui'; +import { api } from '@/lib/api'; +import { formatDate, formatTransactionAmount } from '@/lib/utils'; +import type { AdminTransaction, AdminTransactionPage, TransactionType } from '@/lib/types'; + +const PAGE_SIZE = 50; + +type FilterType = 'all' | TransactionType; + +const FILTERS: { value: FilterType; label: string }[] = [ + { value: 'all', label: 'Все' }, + { value: 'earn', label: 'Начисление' }, + { value: 'spend', label: 'Списание' }, + { value: 'admin_grant', label: 'Вручную' }, + { value: 'expire', label: 'Сгорание' }, +]; + +export default function AdminTransactionsPage() { + const [transactions, setTransactions] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const fetchPage = useCallback(async (currentOffset: number, txFilter: FilterType, append: boolean) => { + if (append) setLoadingMore(true); + else setLoading(true); + setError(null); + + const typeParam = txFilter !== 'all' ? `&type=${txFilter}` : ''; + try { + const page = await api.get( + `/api/v1/admin/transactions?limit=${PAGE_SIZE}&offset=${currentOffset}${typeParam}`, + ); + setTransactions((prev) => (append ? [...prev, ...page.transactions] : page.transactions)); + setTotal(page.total); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка загрузки'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); + + useEffect(() => { + setOffset(0); + fetchPage(0, filter, false); + }, [filter, fetchPage]); + + function handleLoadMore() { + const next = offset + PAGE_SIZE; + setOffset(next); + fetchPage(next, filter, true); + } + + const hasMore = transactions.length < total; + + return ( +
+ +
+

Все транзакции

+ + {/* Type filter */} +
+ {FILTERS.map((f) => ( + + ))} +
+ + {loading && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} + + {!loading && ( + + {transactions.length === 0 ? ( +

Транзакций нет

+ ) : ( + + + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + + ))} + +
СтудентТипСуммаОписаниеДата
{tx.user_email} + + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {formatTransactionAmount(tx.amount)} + + {tx.description || '—'} + + {formatDate(tx.created_at)} +
+ )} +
+ )} + + {!loading && hasMore && ( +
+ +
+ )} + + {!loading && !hasMore && transactions.length > 0 && ( +

+ Показано {transactions.length} из {total} +

+ )} +
+
+ ); +} diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx new file mode 100644 index 0000000..ad9d289 --- /dev/null +++ b/frontend/app/admin/users/page.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { AdminNav } from '@/components/AdminNav'; +import { Button, Card, Spinner } from '@/components/ui'; +import { api } from '@/lib/api'; +import { formatPoints, formatDate } from '@/lib/utils'; +import type { AdminStudent, AdminUsersPage } from '@/lib/types'; + +const PAGE_SIZE = 50; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const fetchPage = useCallback(async (currentOffset: number, append: boolean) => { + if (append) setLoadingMore(true); + else setLoading(true); + setError(null); + + try { + const page = await api.get( + `/api/v1/admin/users?limit=${PAGE_SIZE}&offset=${currentOffset}`, + ); + setUsers((prev) => (append ? [...prev, ...page.users] : page.users)); + setTotal(page.total); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка загрузки'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, []); + + useEffect(() => { + fetchPage(0, false); + }, [fetchPage]); + + function handleLoadMore() { + const next = offset + PAGE_SIZE; + setOffset(next); + fetchPage(next, true); + } + + const hasMore = users.length < total; + + return ( +
+ +
+
+

Студенты

+ {!loading && ( + Всего: {total} + )} +
+ + {loading && ( +
+ +
+ )} + + {error && ( +

{error}

+ )} + + {!loading && ( + + {users.length === 0 ? ( +

Студентов нет

+ ) : ( + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
ИмяEmailStudent IDБалансДата регистрации
{u.name}{u.email} + {u.student_id || '—'} + + {formatPoints(u.balance)} + + {formatDate(u.created_at)} +
+ )} +
+ )} + + {!loading && hasMore && ( +
+ +
+ )} + + {!loading && !hasMore && users.length > 0 && ( +

+ Показано {users.length} из {total} +

+ )} +
+
+ ); +} diff --git a/frontend/components/AdminNav.tsx b/frontend/components/AdminNav.tsx new file mode 100644 index 0000000..fa6d545 --- /dev/null +++ b/frontend/components/AdminNav.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useAuthStore } from '@/lib/store'; + +const LINKS = [ + { href: '/admin/dashboard', label: 'Дашборд' }, + { href: '/admin/grant', label: 'Начислить' }, + { href: '/admin/transactions', label: 'Транзакции' }, + { href: '/admin/users', label: 'Студенты' }, +] as const; + +export function AdminNav() { + const pathname = usePathname(); + const { logout } = useAuthStore(); + + return ( + + ); +} diff --git a/frontend/components/QRScanner.tsx b/frontend/components/QRScanner.tsx new file mode 100644 index 0000000..9ed48c7 --- /dev/null +++ b/frontend/components/QRScanner.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Spinner } from '@/components/ui'; + +interface QRScannerProps { + onScan: (token: string) => void; +} + +export function QRScanner({ onScan }: QRScannerProps) { + const [cameraError, setCameraError] = useState(null); + const [starting, setStarting] = useState(true); + const containerRef = useRef(null); + // Keep a stable ref to onScan so restarting the scanner when the parent + // re-renders (e.g. state changes in the parent) is not needed. + const onScanRef = useRef(onScan); + onScanRef.current = onScan; + const stopRef = useRef<(() => Promise) | null>(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Use a unique id per mount — prevents html5-qrcode conflicts on StrictMode + // double-invoke (the second run would find the first run's leftover DOM). + const id = `qr-${Date.now()}`; + container.id = id; + + let cancelled = false; + + import('html5-qrcode').then(({ Html5Qrcode }) => { + if (cancelled) return; + + const scanner = new Html5Qrcode(id); + stopRef.current = () => scanner.stop(); + + scanner + .start( + { facingMode: 'environment' }, + { fps: 10, qrbox: { width: 220, height: 220 } }, + (text) => { + if (!cancelled) onScanRef.current(text); + scanner.stop().catch(() => {}); + }, + undefined, + ) + .then(() => { + if (!cancelled) setStarting(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setStarting(false); + const msg = String(err).toLowerCase(); + if (msg.includes('permission') || msg.includes('notallowed')) { + setCameraError('Нет доступа к камере. Разрешите доступ в настройках браузера.'); + } else if (msg.includes('notfound') || msg.includes('no camera') || msg.includes('no cameras')) { + setCameraError('Камера не найдена на этом устройстве.'); + } else { + setCameraError('Не удалось запустить камеру.'); + } + }); + }); + + return () => { + cancelled = true; + stopRef.current?.().catch(() => {}); + }; + }, []); // intentionally empty — scanner starts once on mount + + if (cameraError) { + return ( +
+

{cameraError}

+
+ ); + } + + return ( +
+ {starting && ( +
+ +
+ )} + {/* html5-qrcode injects