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
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/cu-points/backend/internal/admin"
"github.com/cu-points/backend/internal/auth"
"github.com/cu-points/backend/internal/config"
"github.com/cu-points/backend/internal/middleware"
"github.com/cu-points/backend/internal/partners"
"github.com/cu-points/backend/internal/points"
"github.com/cu-points/backend/internal/users"
cachepkg "github.com/cu-points/backend/pkg/cache"
dbpkg "github.com/cu-points/backend/pkg/db"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cfg, err := config.Load()
if err != nil {
slog.Error("config load failed", "err", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
db, err := dbpkg.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("postgres connect failed", "err", err)
os.Exit(1)
}
defer db.Close()
redisClient, err := cachepkg.NewClient(ctx, cfg.RedisURL)
if err != nil {
slog.Error("redis connect failed", "err", err)
os.Exit(1)
}
defer redisClient.Close()
slog.Info("infrastructure connected", "postgres", cfg.DatabaseURL, "redis", cfg.RedisURL)
// ── Auth ──────────────────────────────────────────────────────────────────
jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL)
authRepo := auth.NewRepository(db)
authSvc := auth.NewService(authRepo, jwtManager)
authHandler := auth.NewHandler(authSvc)
// ── Users ─────────────────────────────────────────────────────────────────
usersRepo := users.NewRepository(db)
usersSvc := users.NewService(usersRepo)
usersHandler := users.NewHandler(usersSvc)
// ── Partners ──────────────────────────────────────────────────────────────
partnersRepo := partners.NewRepository(db)
partnersSvc := partners.NewService(partnersRepo)
partnersHandler := partners.NewHandler(partnersSvc)
// ── Points ────────────────────────────────────────────────────────────────
pointsRepo := points.NewRepository(db)
pointsCache := points.NewRedisCache(redisClient)
// pointsSvc is also passed to adminSvc so GrantPoints reuses EarnPoints logic.
pointsSvc := points.NewService(pointsRepo, pointsCache, cfg.JWTSecret)
pointsHandler := points.NewHandler(pointsSvc)
// ── Admin ─────────────────────────────────────────────────────────────────
adminSvc := admin.NewService(db, pointsSvc)
adminHandler := admin.NewHandler(adminSvc)
// ── Router ────────────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(middleware.CORS)
r.Use(middleware.Logger)
r.Route("/api/v1", func(r chi.Router) {
// Public — no authentication required.
r.Post("/auth/login", authHandler.Login)
r.Post("/auth/refresh", authHandler.Refresh)
r.Get("/partners", partnersHandler.List)
// Any authenticated user — profile endpoint used by the login flow for all roles.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Get("/me", usersHandler.Me)
})
// Student-only endpoints.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("student"))
r.Get("/me/transactions", usersHandler.Transactions)
r.Get("/me/qr", pointsHandler.GenerateQR)
})
// Partner-only endpoints.
// Rate-limited to 10 spend requests per minute per partner to prevent abuse.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("partner"))
r.Use(middleware.SpendRateLimit(redisClient, 10))
r.Post("/partner/spend", pointsHandler.Spend)
})
// Admin-only endpoints.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("admin"))
r.Post("/admin/points/grant", adminHandler.GrantPoints)
r.Get("/admin/transactions", adminHandler.ListTransactions)
r.Get("/admin/users", adminHandler.ListUsers)
r.Get("/admin/stats", adminHandler.Stats)
})
})
// Health check — no auth required.
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"status":"ok"}`)
})
addr := ":" + cfg.Port
slog.Info("server starting", "addr", addr)
if err := http.ListenAndServe(addr, r); err != nil {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
+84
View File
@@ -0,0 +1,84 @@
// seed inserts development test users into the database.
// Run via: make seed (only use against local dev DB, never production)
package main
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type seedUser struct {
email string
name string
password string
role string
studentID string // empty string → NULL in DB
}
var devUsers = []seedUser{
{
email: "student@cu.ru",
name: "Иван Студентов",
password: "password123",
role: "student",
studentID: "STU001",
},
{
email: "partner@cu.ru",
name: "Кофейня Уют",
password: "password123",
role: "partner",
},
{
email: "admin@cu.ru",
name: "Администратор ЦУ",
password: "password123",
role: "admin",
},
}
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
slog.Error("DATABASE_URL is not set")
os.Exit(1)
}
ctx := context.Background()
db, err := pgxpool.New(ctx, dbURL)
if err != nil {
slog.Error("connect failed", "err", err)
os.Exit(1)
}
defer db.Close()
for _, u := range devUsers {
hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
if err != nil {
slog.Error("bcrypt failed", "email", u.email, "err", err)
os.Exit(1)
}
// NULLIF converts empty string to NULL for student_id
_, err = db.Exec(ctx, `
INSERT INTO users (email, name, password_hash, role, student_id)
VALUES ($1, $2, $3, $4, NULLIF($5, ''))
ON CONFLICT (email) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
name = EXCLUDED.name
`, u.email, u.name, string(hash), u.role, u.studentID)
if err != nil {
slog.Error("seed insert failed", "email", u.email, "err", err)
os.Exit(1)
}
fmt.Printf("seeded: %-30s role=%-8s password=%s\n", u.email, u.role, u.password)
}
fmt.Println("\nDone. Test credentials above are for local development only.")
}