Files
CU_Points/backend/internal/admin/handler.go
T
emilandClaude Sonnet 4.6 be2260d259 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>
2026-05-01 16:41:17 +03:00

143 lines
4.3 KiB
Go

// Package admin handles administration endpoints: granting points, viewing stats.
package admin
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"github.com/cu-points/backend/pkg/response"
)
// Handler holds HTTP handler methods for the admin domain.
type Handler struct {
service *Service
}
// NewHandler creates a new admin Handler.
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
// grantRequest is the expected JSON body for POST /api/v1/admin/points/grant.
type grantRequest struct {
UserID string `json:"user_id"`
Amount int `json:"amount"`
Description string `json:"description"`
}
// transactionsResponse is the JSON body returned by GET /api/v1/admin/transactions.
type transactionsResponse struct {
Transactions []AdminTransaction `json:"transactions"`
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.
func (h *Handler) GrantPoints(w http.ResponseWriter, r *http.Request) {
var req grantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
response.Error(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.UserID == "" {
response.Error(w, http.StatusBadRequest, "user_id is required")
return
}
if req.Amount <= 0 {
response.Error(w, http.StatusBadRequest, "amount must be positive")
return
}
if err := h.service.GrantPoints(r.Context(), req.UserID, req.Amount, req.Description); err != nil {
slog.Error("handler.GrantPoints", "err", err)
response.Error(w, http.StatusInternalServerError, "internal server error")
return
}
response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// 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), type (optional filter).
func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) {
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
}
}
txType := r.URL.Query().Get("type")
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")
return
}
if txs == nil {
txs = []AdminTransaction{}
}
response.JSON(w, http.StatusOK, transactionsResponse{
Transactions: txs,
Total: total,
})
}
// ListUsers handles GET /api/v1/admin/users.
// 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) {
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")
return
}
if students == nil {
students = []Student{}
}
response.JSON(w, http.StatusOK, usersResponse{Users: students, Total: total})
}
// Stats handles GET /api/v1/admin/stats.
// Returns aggregated statistics: total students, points issued/spent, active partners.
func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) {
stats, err := h.service.GetStats(r.Context())
if err != nil {
slog.Error("handler.Stats", "err", err)
response.Error(w, http.StatusInternalServerError, "internal server error")
return
}
response.JSON(w, http.StatusOK, stats)
}