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:
emil
2026-05-01 10:03:27 +03:00
co-authored by Claude Sonnet 4.6
commit 50b3c4198a
80 changed files with 10579 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
// Package middleware provides HTTP middleware: JWT verification, role guard, request logging.
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// contextKey is an unexported type for context keys in this package,
// preventing collisions with keys set by other packages.
type contextKey string
const (
userIDKey contextKey = "user_id"
userRoleKey contextKey = "user_role"
jtiKey contextKey = "jti"
)
// tokenClaims mirrors the JWT payload fields the middleware needs to inspect.
// Defined locally so the middleware does not import the auth package.
type tokenClaims struct {
jwt.RegisteredClaims // provides Subject (user_id), JWTID (jti), ExpiresAt
Role string `json:"role"`
Type string `json:"type"`
}
// UserIDFromContext retrieves the authenticated user's ID stored by Auth middleware.
func UserIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(userIDKey).(string)
return v
}
// UserRoleFromContext retrieves the authenticated user's role stored by Auth middleware.
func UserRoleFromContext(ctx context.Context) string {
v, _ := ctx.Value(userRoleKey).(string)
return v
}
// JTIFromContext retrieves the JWT ID (jti) stored by Auth middleware.
// Useful for token revocation checks in downstream handlers.
func JTIFromContext(ctx context.Context) string {
v, _ := ctx.Value(jtiKey).(string)
return v
}
// Auth returns middleware that validates the Bearer JWT in the Authorization header.
// On success it injects user_id, role, and jti into the request context.
// Rejects tokens that are expired, have a bad signature, or are not of type "access"
// (prevents refresh tokens from being used on protected endpoints).
func Auth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr, err := bearerToken(r)
if err != nil {
http.Error(w, `{"error":"missing or invalid Authorization header"}`, http.StatusUnauthorized)
return
}
claims := &tokenClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("middleware.Auth: unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
// Explicitly block refresh tokens from reaching protected endpoints.
if claims.Type != "access" {
http.Error(w, `{"error":"access token required"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
ctx = context.WithValue(ctx, userRoleKey, claims.Role)
ctx = context.WithValue(ctx, jtiKey, claims.ID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// bearerToken extracts the token string from the Authorization: Bearer <token> header.
func bearerToken(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
return "", fmt.Errorf("middleware.bearerToken: missing Bearer prefix")
}
return strings.TrimPrefix(h, "Bearer "), nil
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import "net/http"
// CORS adds permissive CORS headers for local development.
// Handles the browser preflight OPTIONS request so chi doesn't return 405.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+34
View File
@@ -0,0 +1,34 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
// Logger is structured request-logging middleware using log/slog.
// It records method, path, status code, and response duration for every request.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"context"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
// SpendRateLimit returns middleware that limits requests to maxPerMinute per
// authenticated partner (identified by user_id in context). It must be placed
// after Auth + RequireRole("partner") so that UserIDFromContext is populated.
//
// Implementation: Redis INCR + EXPIRE sliding-window counter.
// Key: rate_limit:spend:<partner_id> — expires after 1 minute.
// On Redis failure the middleware fails open (lets the request through) so that
// a Redis outage does not take down point transactions.
func SpendRateLimit(rdb *redis.Client, maxPerMinute int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
partnerID := UserIDFromContext(r.Context())
key := "rate_limit:spend:" + partnerID
// Use a short-lived context for Redis so a slow Redis doesn't stall the request.
rCtx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
count, err := rdb.Incr(rCtx, key).Result()
if err != nil {
// Fail open: Redis unavailable should not block transactions.
next.ServeHTTP(w, r)
return
}
// Set the expiry only on the first increment so the window resets each minute.
if count == 1 {
rdb.Expire(rCtx, key, time.Minute) //nolint:errcheck
}
if count > int64(maxPerMinute) {
http.Error(w, `{"error":"rate limit exceeded, max 10 requests per minute"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package middleware
import "net/http"
// RequireRole returns middleware that allows only requests whose authenticated user
// holds one of the permitted roles. Must be chained after Auth middleware.
func RequireRole(allowed ...string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, r := range allowed {
set[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := set[UserRoleFromContext(r.Context())]; !ok {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}