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:
@@ -0,0 +1,12 @@
|
||||
# Backend
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/cupoints?sslmode=disable
|
||||
DATABASE_TEST_URL=postgres://dev:dev@localhost:5433/cupoints_test?sslmode=disable
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=your-secret-key-minimum-32-characters-here
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=168h
|
||||
PORT=8082
|
||||
ENV=development
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8082
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Backend CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_DB: cupoints_test
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Run migrations
|
||||
env:
|
||||
GOOSE_DRIVER: postgres
|
||||
GOOSE_DBSTRING: postgres://dev:dev@localhost:5432/cupoints_test?sslmode=disable
|
||||
run: |
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
goose -dir migrations up
|
||||
|
||||
- name: go test
|
||||
working-directory: backend
|
||||
env:
|
||||
DATABASE_URL: postgres://dev:dev@localhost:5432/cupoints_test?sslmode=disable
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: test-secret-key-minimum-32-characters-long
|
||||
JWT_ACCESS_TTL: 15m
|
||||
JWT_REFRESH_TTL: 168h
|
||||
PORT: 8080
|
||||
ENV: test
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-coverage
|
||||
path: backend/coverage.out
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Frontend CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint-typecheck-build:
|
||||
name: Lint, Type-check & Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
working-directory: frontend
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: ESLint
|
||||
working-directory: frontend
|
||||
run: npx eslint .
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
env:
|
||||
NEXT_PUBLIC_API_URL: http://localhost:8080
|
||||
run: npm run build
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Go
|
||||
backend/vendor/
|
||||
backend/coverage.out
|
||||
backend/coverage.html
|
||||
|
||||
# Air (Go hot-reload)
|
||||
backend/tmp/
|
||||
|
||||
# Node / Next.js
|
||||
frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/out/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
# CU Points — Архитектура проекта
|
||||
|
||||
> Документ для команды. Обновляется по мере принятия архитектурных решений.
|
||||
> Последнее обновление: апрель 2026.
|
||||
|
||||
---
|
||||
|
||||
## Концепция
|
||||
|
||||
Поинтовая система лояльности для студентов ЦУ — аналог Innopolis Club.
|
||||
|
||||
**Ценность для студента:** зарабатывай поинты за учёбу, трать на кофе и еду рядом с кампусом.
|
||||
**Ценность для партнёра:** гарантированный поток студентов, компенсация от ЦУ.
|
||||
**Ценность для ЦУ:** инструмент мотивации студенческой активности.
|
||||
|
||||
---
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Что может |
|
||||
|------|-----------|
|
||||
| `student` | смотреть баланс, историю, показывать QR для оплаты |
|
||||
| `partner` | сканировать QR студентов, списывать поинты |
|
||||
| `admin` | начислять поинты, смотреть статистику, управлять партнёрами |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура (высокий уровень)
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────┐
|
||||
│ КЛИЕНТЫ (браузер) │
|
||||
│ Студент (web) Партнёр (web) Админ (web) │
|
||||
└──────────────────────┬─────────────────────────┘
|
||||
│ HTTPS / REST API
|
||||
│
|
||||
┌──────────────────────▼─────────────────────────┐
|
||||
│ Go REST API (:8080) │
|
||||
│ │
|
||||
│ /auth /points /partners /admin │
|
||||
│ │
|
||||
│ middleware: JWT проверка, role guard │
|
||||
└────────┬───────────────────────┬───────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌─────────▼──────┐
|
||||
│ PostgreSQL 16 │ │ Redis 7 │
|
||||
│ (source of │ │ - QR-токены │
|
||||
│ truth) │ │ - сессии │
|
||||
└─────────────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ключевые флоу
|
||||
|
||||
### Флоу 1: Студент тратит поинты у партнёра (QR)
|
||||
|
||||
```
|
||||
Студент (браузер) Кассир партнёра Go API
|
||||
│ │ │
|
||||
│ GET /me/qr │ │
|
||||
│───────────────────────────────────────────▶ │
|
||||
│◀─────────────────────────────────────────── │
|
||||
│ {qr_token: JWT 5min} │ │
|
||||
│ │ │
|
||||
│ [показывает QR] │ │
|
||||
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │ │
|
||||
│ │ POST /partner/spend│
|
||||
│ │ {qr_token, amount} │
|
||||
│ │───────────────────▶│
|
||||
│ │ │ 1. валидировать JWT
|
||||
│ │ │ 2. проверить одноразовость (Redis)
|
||||
│ │ │ 3. проверить баланс ≥ amount
|
||||
│ │ │ 4. BEGIN TRANSACTION
|
||||
│ │ │ UPDATE users SET balance -= amount
|
||||
│ │ │ INSERT INTO transactions
|
||||
│ │ │ 5. COMMIT
|
||||
│ │ │ 6. записать jti в Redis (TTL 5min)
|
||||
│ │◀───────────────────│
|
||||
│ │ {success, new_bal} │
|
||||
```
|
||||
|
||||
**Почему QR, а не интеграция с кассой:**
|
||||
Федеральные сети (Додо, Дринкит) имеют собственное кассовое ПО и не дадут интеграцию
|
||||
стартапу без длительных переговоров. QR-флоу — самодостаточное решение,
|
||||
работает с любым партнёром у которого есть смартфон.
|
||||
|
||||
### Флоу 2: Начисление поинтов студенту
|
||||
|
||||
Источники поинтов (финальный список определят аналитики, здесь — возможные варианты):
|
||||
|
||||
| Триггер | Кто инициирует | Примерное количество |
|
||||
|---------|----------------|----------------------|
|
||||
| Посещение занятия | Администратор / интеграция с LMS | 5–10 поинтов |
|
||||
| Сдача задания вовремя | Администратор / интеграция с LMS | 10–20 поинтов |
|
||||
| Реферал (пригласил друга) | Автоматически | 50–100 поинтов |
|
||||
| Ручное начисление (победа в конкурсе и т.д.) | Администратор | любое |
|
||||
|
||||
**MVP:** только ручное начисление через дашборд администратора.
|
||||
**V1:** интеграция с LMS/системой посещаемости ЦУ.
|
||||
|
||||
---
|
||||
|
||||
## База данных (полная схема)
|
||||
|
||||
```sql
|
||||
-- users: студенты, партнёры, администраторы
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
student_id TEXT UNIQUE,
|
||||
role TEXT NOT NULL CHECK (role IN ('student', 'partner', 'admin')),
|
||||
balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- partners: метаданные точек-партнёров
|
||||
CREATE TABLE partners (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
max_spend_pct INTEGER NOT NULL DEFAULT 50,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- transactions: append-only лог всех операций с поинтами
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
partner_id UUID REFERENCES partners(id),
|
||||
amount INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('earn', 'spend', 'admin_grant', 'expire')),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- earning_rules: настраиваемые правила начисления
|
||||
CREATE TABLE earning_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
points_amount INTEGER NOT NULL,
|
||||
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('attendance', 'assignment', 'referral', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON transactions(user_id, created_at DESC);
|
||||
CREATE INDEX ON transactions(partner_id, created_at DESC);
|
||||
```
|
||||
|
||||
**Почему `balance` в таблице `users`, а не вычисляется из транзакций:**
|
||||
Вычислять баланс через `SUM(transactions)` — медленно при большой истории.
|
||||
Храним `balance` как денормализованное поле, обновляем атомарно вместе с транзакцией.
|
||||
PostgreSQL `CHECK (balance >= 0)` — последняя линия защиты от отрицательного баланса.
|
||||
|
||||
---
|
||||
|
||||
## Структура Go-бэкенда (детально)
|
||||
|
||||
```
|
||||
backend/internal/points/service.go ← самый важный файл в проекте
|
||||
|
||||
// Пример структуры сервиса
|
||||
type Service struct {
|
||||
repo Repository // интерфейс для моков в тестах
|
||||
cache cache.Client
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (s *Service) SpendPoints(ctx context.Context, req SpendRequest) error {
|
||||
// 1. Проверить QR-токен (одноразовость через Redis)
|
||||
// 2. Загрузить пользователя
|
||||
// 3. Проверить баланс >= req.Amount
|
||||
// 4. Проверить лимит (amount <= purchase_total * max_spend_pct / 100)
|
||||
// 5. BEGIN TRANSACTION
|
||||
// UPDATE users SET balance = balance - req.Amount WHERE id = req.UserID
|
||||
// INSERT INTO transactions (...)
|
||||
// 6. COMMIT
|
||||
// 7. Записать jti в Redis (TTL 5 минут)
|
||||
}
|
||||
```
|
||||
|
||||
**Паттерн Repository:** каждый пакет определяет интерфейс `Repository`,
|
||||
что позволяет писать unit-тесты без реальной БД (mock-реализация).
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
| Угроза | Защита |
|
||||
|--------|--------|
|
||||
| Отрицательный баланс | `CHECK (balance >= 0)` + проверка в сервисе |
|
||||
| Повторное использование QR | Redis: `used_qr:<jti>` с TTL 5 мин |
|
||||
| Подделка роли | Role guard middleware + JWT claims |
|
||||
| SQL-инъекции | Только параметризованные запросы (pgx/sqlx) |
|
||||
| Утечка токенов | Access token TTL = 15 минут |
|
||||
| Brute force логина | Rate limiting по IP (middleware) |
|
||||
|
||||
---
|
||||
|
||||
## Деплой (Yandex Cloud)
|
||||
|
||||
```
|
||||
Yandex Cloud
|
||||
├── Application Load Balancer
|
||||
│ └── TLS-терминация (сертификат от Let's Encrypt через YC Certificate Manager)
|
||||
├── Container Registry
|
||||
│ ├── cu-points-backend:latest
|
||||
│ └── cu-points-frontend:latest
|
||||
├── Compute Cloud (или Serverless Containers)
|
||||
│ ├── backend (2 реплики, 1 vCPU / 1 GB RAM каждая)
|
||||
│ └── frontend (2 реплики)
|
||||
├── Managed Service for PostgreSQL
|
||||
│ └── HA-кластер (1 мастер + 1 реплика)
|
||||
└── Managed Service for Redis
|
||||
└── 1 инстанс
|
||||
```
|
||||
|
||||
**Локальная разработка:**
|
||||
```yaml
|
||||
# docker-compose.yml (минимальный)
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: cupoints
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
ports: ["5432:5432"]
|
||||
volumes: ["pgdata:/var/lib/postgresql/data"]
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports: ["6379:6379"]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Правовые требования
|
||||
|
||||
Чтобы поинты не облагались НДФЛ у студентов (п.68 ст.217 НК РФ):
|
||||
|
||||
1. **Публичная оферта** — правила программы опубликованы на сайте ЦУ, доступны без авторизации.
|
||||
2. **Срок акцепта ≥ 30 дней** — прописать в условиях программы.
|
||||
3. **Студенты ≠ сотрудники** — поинты за учёбу, не за трудовые обязательства.
|
||||
4. **Юрлицо-оператор** — ООО (можно ЦУ или отдельное юрлицо), которое заключает договоры с партнёрами и компенсирует им потраченные студентами поинты.
|
||||
|
||||
---
|
||||
|
||||
## Дорожная карта
|
||||
|
||||
### MVP
|
||||
- [ ] Инфраструктура (docker-compose, миграции, CI)
|
||||
- [ ] Auth (JWT, роли)
|
||||
- [ ] Модель транзакций + баланс
|
||||
- [ ] QR-флоу списания у партнёра
|
||||
- [ ] Кабинет студента (баланс, история, QR)
|
||||
- [ ] Ручное начисление администратором
|
||||
- [ ] 2–3 партнёра-пилота рядом с кампусом
|
||||
|
||||
### V1 (после MVP)
|
||||
- [ ] Автоначисление через интеграцию с LMS ЦУ
|
||||
- [ ] Дашборд аналитики (для аналитиков и руководства)
|
||||
- [ ] Реферальная программа
|
||||
- [ ] PWA для мобильных (без нативного приложения)
|
||||
|
||||
### V2
|
||||
- [ ] Срок жизни поинтов (expire)
|
||||
- [ ] Уровни участников (Silver / Gold / Platinum)
|
||||
- [ ] Push-уведомления
|
||||
- [ ] API для партнёров (без QR — прямая интеграция для тех кто готов)
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы (для команды)
|
||||
|
||||
- [ ] **Откуда студент получает поинты?** Финальный список триггеров — на аналитиках.
|
||||
- [ ] **Кто оператор программы?** Нужно ли отдельное ООО или достаточно ЦУ?
|
||||
- [ ] **Как партнёр получает компенсацию?** Ежемесячный акт или автоматически? — на экономисте.
|
||||
- [ ] **Лимит 50% от суммы покупки** — нужно согласовать с партнёрами.
|
||||
- [ ] **Дизайн** — Figma-макеты от дизайнера до начала разработки фронтенда.
|
||||
@@ -0,0 +1,358 @@
|
||||
# CU Points — CLAUDE.md
|
||||
|
||||
## Что это за проект
|
||||
|
||||
Поинтовая система лояльности для студентов Центрального Университета (Москва).
|
||||
Студенты зарабатывают поинты за активность в ЦУ и тратят их у партнёров
|
||||
(локальные кофейни, столовые, магазины рядом с кампусом).
|
||||
|
||||
Аналог Innopolis Club, адаптированный для Москвы. Партнёры — небольшой локальный
|
||||
бизнес, не федеральные сети.
|
||||
|
||||
**Статус:** проект с нуля, чистый репозиторий.
|
||||
|
||||
---
|
||||
|
||||
## Команда
|
||||
|
||||
| Роль | Описание |
|
||||
|------|----------|
|
||||
| Тимлид / Продакт / Разраб | Emil — основной пользователь Claude Code |
|
||||
| Backend-разработчик | разный уровень Go |
|
||||
| Frontend-разработчик | разный уровень Next.js |
|
||||
| Аналитик × 2 | требования, исследования, метрики |
|
||||
| Дизайнер | UI/UX, Figma → компоненты |
|
||||
| Экономист | бизнес-модель, партнёрские условия |
|
||||
|
||||
> **Важно для Claude Code:** в команде разный уровень Go и Next.js.
|
||||
> Код должен быть хорошо прокомментирован, структура — предсказуемой,
|
||||
> сложные паттерны — объяснены в комментарии над функцией.
|
||||
|
||||
---
|
||||
|
||||
## Стек
|
||||
|
||||
| Слой | Технология |
|
||||
|------|-----------|
|
||||
| Backend | Go 1.22+ |
|
||||
| Frontend | Next.js 14 (App Router) + TypeScript |
|
||||
| Стили | Tailwind CSS |
|
||||
| БД | PostgreSQL 16 |
|
||||
| Кэш / сессии | Redis 7 |
|
||||
| Миграции | goose |
|
||||
| Контейнеры | Docker + Docker Compose |
|
||||
| Репозиторий | GitHub |
|
||||
| CI/CD | GitHub Actions |
|
||||
| Деплой | Yandex Cloud |
|
||||
| Трекер задач | Kanban (Notion) |
|
||||
|
||||
---
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```
|
||||
cu-points/
|
||||
├── backend/
|
||||
│ ├── cmd/api/
|
||||
│ │ └── main.go # точка входа: инициализация, DI, запуск
|
||||
│ ├── internal/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── config.go # конфиг из env-переменных
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── handler.go # HTTP-хендлеры (только парсинг запроса/ответа)
|
||||
│ │ │ ├── service.go # бизнес-логика аутентификации
|
||||
│ │ │ ├── repository.go # SQL-запросы
|
||||
│ │ │ └── jwt.go # генерация и валидация JWT
|
||||
│ │ ├── points/
|
||||
│ │ │ ├── handler.go
|
||||
│ │ │ ├── service.go # earn/spend — самый критичный слой
|
||||
│ │ │ └── repository.go
|
||||
│ │ ├── users/
|
||||
│ │ │ ├── handler.go
|
||||
│ │ │ ├── service.go
|
||||
│ │ │ └── repository.go
|
||||
│ │ ├── partners/
|
||||
│ │ │ ├── handler.go
|
||||
│ │ │ ├── service.go
|
||||
│ │ │ └── repository.go
|
||||
│ │ ├── admin/
|
||||
│ │ │ ├── handler.go
|
||||
│ │ │ └── service.go
|
||||
│ │ └── middleware/
|
||||
│ │ ├── auth.go # проверка JWT, прокидывание user_id в контекст
|
||||
│ │ ├── role.go # проверка роли (student/partner/admin)
|
||||
│ │ └── logger.go # структурированное логирование запросов
|
||||
│ └── pkg/
|
||||
│ ├── db/postgres.go # инициализация pgx pool
|
||||
│ ├── cache/redis.go # инициализация Redis клиента
|
||||
│ └── response/json.go # стандартные JSON-ответы (success/error)
|
||||
├── frontend/
|
||||
│ ├── app/
|
||||
│ │ ├── (auth)/login/page.tsx
|
||||
│ │ ├── (student)/
|
||||
│ │ │ ├── dashboard/page.tsx # баланс + последние операции
|
||||
│ │ │ ├── history/page.tsx # полная история транзакций
|
||||
│ │ │ ├── partners/page.tsx # список партнёров
|
||||
│ │ │ └── qr/page.tsx # QR-код для оплаты
|
||||
│ │ ├── (partner)/
|
||||
│ │ │ └── scan/page.tsx # сканер QR + форма суммы
|
||||
│ │ └── (admin)/
|
||||
│ │ ├── dashboard/page.tsx
|
||||
│ │ └── grant/page.tsx # ручное начисление поинтов
|
||||
│ ├── components/
|
||||
│ │ ├── ui/ # атомарные компоненты (Button, Input...)
|
||||
│ │ ├── BalanceCard.tsx
|
||||
│ │ ├── TransactionList.tsx
|
||||
│ │ ├── QRDisplay.tsx
|
||||
│ │ └── PartnerCard.tsx
|
||||
│ ├── lib/
|
||||
│ │ ├── api.ts # fetch-обёртка с baseURL и токеном
|
||||
│ │ ├── store.ts # Zustand: глобальный стейт
|
||||
│ │ ├── types.ts # типы для API-ответов
|
||||
│ │ └── utils.ts # форматирование дат, чисел
|
||||
│ └── middleware.ts # защита роутов по роли
|
||||
├── migrations/
|
||||
│ ├── 00001_init_users.sql
|
||||
│ ├── 00002_init_partners.sql
|
||||
│ ├── 00003_init_transactions.sql
|
||||
│ └── 00004_init_earning_rules.sql
|
||||
├── .github/workflows/
|
||||
│ ├── backend-ci.yml
|
||||
│ └── frontend-ci.yml
|
||||
├── docker-compose.yml
|
||||
├── Makefile
|
||||
├── .env.example
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Соглашения по коду
|
||||
|
||||
### Go (Backend)
|
||||
|
||||
- **Архитектура:** строго `handler → service → repository`. Бизнес-логика **только в service**.
|
||||
- **Именование файлов:** `snake_case`. Пакеты: короткие, без подчёркиваний.
|
||||
- **Ошибки:** всегда явные, никогда не игнорировать `err`. Оборачивать через `fmt.Errorf("context: %w", err)`.
|
||||
- **HTTP-роутер:** стандартная библиотека `net/http` + `chi`. Не использовать gin/echo/fiber без обсуждения с тимлидом.
|
||||
- **БД:** только `pgx/v5` или `sqlx` с raw SQL. ORM (gorm и т.п.) — **запрещены**.
|
||||
- **Логирование:** `log/slog` (стандартная библиотека Go 1.21+), структурированные поля.
|
||||
- **Конфиг:** только через `internal/config/config.go`. Никаких magic strings по всему коду.
|
||||
- **Комментарии:** обязательны над каждой экспортируемой функцией и над нетривиальной логикой. На английском.
|
||||
|
||||
```go
|
||||
// SpendPoints debits the given amount from the user's balance
|
||||
// and records a transaction atomically in a single DB transaction.
|
||||
// Returns ErrInsufficientBalance if balance < amount.
|
||||
func (s *Service) SpendPoints(ctx context.Context, req SpendRequest) error {
|
||||
```
|
||||
|
||||
### TypeScript / Next.js (Frontend)
|
||||
|
||||
- **Роутер:** App Router (не Pages Router).
|
||||
- **Стейт:** Zustand — глобальный (user, balance). `useState` — локальный UI-стейт.
|
||||
- **Запросы к API:** только через `lib/api.ts`. **Не использовать axios**.
|
||||
- **Компоненты:** функциональные, именованные экспорты: `export function BalanceCard(...)`.
|
||||
- **Типы:** строгая типизация. **`any` — запрещён**. Типы API-ответов в `lib/types.ts`.
|
||||
- **Стили:** Tailwind CSS. Кастомный CSS только если Tailwind не покрывает случай.
|
||||
|
||||
### Общие правила
|
||||
|
||||
- Названия переменных, функций, комментарии — **на английском**.
|
||||
- Commit messages: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:` + описание на английском.
|
||||
Пример: `feat: add QR token generation endpoint`
|
||||
- `TODO` только с ссылкой на задачу: `// TODO(notion:TASK-42): handle expired tokens`
|
||||
- Все API-эндпоинты с префиксом `/api/v1/`.
|
||||
|
||||
---
|
||||
|
||||
## Git-стратегия
|
||||
|
||||
Trunk-based flow — простой и подходящий для команды нашего размера:
|
||||
|
||||
```
|
||||
main ← всегда стабильная, деплоится автоматически
|
||||
└── feature/auth-jwt
|
||||
└── feature/points-spend-qr
|
||||
└── fix/balance-negative-edge-case
|
||||
```
|
||||
|
||||
- **Никогда не пушить напрямую в `main`.**
|
||||
- Каждая задача из Notion — отдельная ветка: `feature/<название>` или `fix/<название>`.
|
||||
- Перед мержем — PR с ревью минимум одного человека.
|
||||
- CI (lint + tests) должен быть зелёным перед мержем.
|
||||
|
||||
---
|
||||
|
||||
## Модель данных
|
||||
|
||||
```sql
|
||||
-- Пользователи (студенты, партнёры, администраторы)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
student_id TEXT UNIQUE, -- только для студентов ЦУ
|
||||
role TEXT NOT NULL CHECK (role IN ('student', 'partner', 'admin')),
|
||||
balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Партнёры (кофейни, столовые и т.д.)
|
||||
CREATE TABLE partners (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id), -- аккаунт кассира
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
max_spend_pct INTEGER NOT NULL DEFAULT 50, -- макс. % покупки оплатить поинтами
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Транзакции — append-only лог, НИКОГДА не удалять записи
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
partner_id UUID REFERENCES partners(id), -- NULL для earn-транзакций
|
||||
amount INTEGER NOT NULL, -- > 0 earn, < 0 spend
|
||||
type TEXT NOT NULL CHECK (type IN ('earn', 'spend', 'admin_grant', 'expire')),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Правила начисления (настраиваются администратором)
|
||||
CREATE TABLE earning_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
points_amount INTEGER NOT NULL,
|
||||
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('attendance', 'assignment', 'referral', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON transactions(user_id, created_at DESC);
|
||||
CREATE INDEX ON transactions(partner_id, created_at DESC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Бизнес-правила (критически важно)
|
||||
|
||||
1. **Баланс ≥ 0 всегда** — двойная защита: `CHECK (balance >= 0)` в PostgreSQL + проверка в `points.Service` перед списанием.
|
||||
2. **Транзакции атомарны** — списание баланса и запись транзакции — одна SQL-транзакция (`BEGIN / COMMIT`).
|
||||
3. **Поинты не конвертируются в рубли** — студент не может вывести поинты деньгами.
|
||||
4. **Курс:** 1 поинт = 1 рубль у партнёра. Партнёр получает компенсацию от ЦУ по договору.
|
||||
5. **Лимит списания:** не более `max_spend_pct`% (по умолчанию 50%) от суммы покупки.
|
||||
6. **QR-токен одноразовый** — JWT с TTL 5 минут. После использования — записать в Redis (ключ = `used_qr:<jti>`, TTL 5 минут) для защиты от повторного использования.
|
||||
7. **Публичная оферта** — правила программы доступны без авторизации (требование НК РФ п.68 ст.217).
|
||||
|
||||
---
|
||||
|
||||
## API эндпоинты (MVP)
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login # email + password → {access_token, refresh_token}
|
||||
POST /api/v1/auth/refresh # {refresh_token} → {access_token}
|
||||
|
||||
GET /api/v1/me # профиль + текущий баланс
|
||||
GET /api/v1/me/transactions # история (query: limit, offset)
|
||||
GET /api/v1/me/qr # сгенерировать QR-токен (TTL 5 мин)
|
||||
|
||||
GET /api/v1/partners # список активных партнёров (публично)
|
||||
|
||||
POST /api/v1/partner/spend # {qr_token, amount} → списать поинты
|
||||
# доступно только роли 'partner'
|
||||
|
||||
POST /api/v1/admin/points/grant # {user_id, amount, description}
|
||||
GET /api/v1/admin/transactions # все транзакции системы
|
||||
GET /api/v1/admin/users # список студентов + балансы
|
||||
GET /api/v1/admin/stats # агрегированная статистика
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Файл `.env.example` в корне репозитория:
|
||||
|
||||
```env
|
||||
# Backend
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/cupoints?sslmode=disable
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=your-secret-key-minimum-32-characters
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=168h
|
||||
PORT=8080
|
||||
ENV=development
|
||||
|
||||
# Frontend
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
**Никогда не коммитить `.env`** — только `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
## Makefile
|
||||
|
||||
```makefile
|
||||
make docker-up # поднять postgres + redis
|
||||
make docker-down # остановить
|
||||
make migrate-up # применить все миграции
|
||||
make migrate-down # откатить последнюю миграцию
|
||||
make run-backend # запустить Go API (hot reload через air)
|
||||
make run-frontend # запустить Next.js dev
|
||||
make test # go test ./... + jest
|
||||
make test-coverage # coverage report
|
||||
make lint # golangci-lint + eslint + tsc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
- **Unit-тесты** обязательны для `internal/points/service.go` и `internal/auth/service.go`.
|
||||
- **Integration-тесты** для критических путей: earn, spend, граничный случай (баланс = 0).
|
||||
- Тестовая БД: `cupoints_test` в docker-compose.
|
||||
- **Целевое покрытие:** ≥ 70% для пакетов `points` и `transactions`.
|
||||
|
||||
---
|
||||
|
||||
## CI (GitHub Actions)
|
||||
|
||||
На каждый PR в `main`:
|
||||
1. `golangci-lint`
|
||||
2. `go test ./...`
|
||||
3. `tsc --noEmit` + `eslint`
|
||||
4. Docker build (проверка что образы собираются)
|
||||
|
||||
Мерж только при зелёном CI.
|
||||
|
||||
---
|
||||
|
||||
## Чего НЕ делать
|
||||
|
||||
- Не хранить баланс только в Redis — PostgreSQL source of truth.
|
||||
- Не писать бизнес-логику в хендлерах.
|
||||
- Не использовать ORM.
|
||||
- Не удалять записи из `transactions`.
|
||||
- Не конвертировать поинты в рубли напрямую студенту.
|
||||
- Не пушить в `main` напрямую.
|
||||
- Не коммитить `.env`, ключи, пароли.
|
||||
- Не использовать `any` в TypeScript.
|
||||
|
||||
---
|
||||
|
||||
## Приоритет задач (MVP)
|
||||
|
||||
1. `docker-compose.yml` + `Makefile` + `.env.example`
|
||||
2. Миграции (все 4 таблицы)
|
||||
3. Auth: login, JWT, middleware проверки роли
|
||||
4. Points: earn/spend с атомарностью и проверкой баланса
|
||||
5. QR: генерация токена + валидация (одноразовость через Redis)
|
||||
6. REST API студента (me, transactions, qr)
|
||||
7. REST API партнёра (spend)
|
||||
8. REST API администратора (grant, stats)
|
||||
9. Next.js: кабинет студента (баланс, история, QR)
|
||||
10. Next.js: интерфейс партнёра (сканер + форма суммы)
|
||||
11. Next.js: дашборд администратора
|
||||
@@ -0,0 +1,45 @@
|
||||
# Load .env if present — exports all variables to sub-processes
|
||||
-include .env
|
||||
export
|
||||
|
||||
.PHONY: docker-up docker-down migrate-up migrate-down \
|
||||
run-backend run-frontend test test-coverage lint seed
|
||||
|
||||
## Infrastructure
|
||||
docker-up:
|
||||
docker compose up -d postgres redis
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
## Migrations (requires goose: go install github.com/pressly/goose/v3/cmd/goose@latest)
|
||||
migrate-up:
|
||||
PATH=$$HOME/go/bin:$$PATH goose -dir migrations postgres "$(DATABASE_URL)" up
|
||||
|
||||
migrate-down:
|
||||
PATH=$$HOME/go/bin:$$PATH goose -dir migrations postgres "$(DATABASE_URL)" down
|
||||
|
||||
## Development servers
|
||||
run-backend:
|
||||
cd backend && PATH=$$HOME/go/bin:$$PATH air
|
||||
|
||||
# PORT is exported from .env (backend uses it); unset it so Next.js defaults to 3000.
|
||||
run-frontend:
|
||||
cd frontend && env -u PORT npm run dev
|
||||
|
||||
## Testing
|
||||
test:
|
||||
cd backend && go test ./... && cd ../frontend && npm test -- --passWithNoTests
|
||||
|
||||
test-coverage:
|
||||
cd backend && go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report: backend/coverage.html"
|
||||
|
||||
## Dev seed (local DB only — never run against production)
|
||||
seed:
|
||||
cd backend && go run ./cmd/seed
|
||||
|
||||
## Linting
|
||||
lint:
|
||||
cd backend && golangci-lint run ./...
|
||||
cd frontend && npx tsc --noEmit && npx eslint .
|
||||
@@ -0,0 +1,8 @@
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/main ./cmd/api"
|
||||
bin = "./tmp/main"
|
||||
include_ext = ["go"]
|
||||
exclude_dir = ["tmp", "vendor"]
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
module github.com/cu-points/backend
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
golang.org/x/crypto v0.17.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
|
||||
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Response: { "transactions": [...], "total": N }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
txs, total, err := h.service.ListTransactions(r.Context(), limit, offset)
|
||||
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 their current balances.
|
||||
func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
students, err := h.service.ListStudents(r.Context())
|
||||
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, students)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/cu-points/backend/internal/points"
|
||||
)
|
||||
|
||||
// Service handles business logic for administrative operations.
|
||||
type Service struct {
|
||||
db *pgxpool.Pool
|
||||
points *points.Service
|
||||
}
|
||||
|
||||
// NewService creates a new admin Service.
|
||||
// pointsSvc is used for all balance mutations so that earn logic is not duplicated here.
|
||||
func NewService(db *pgxpool.Pool, pointsSvc *points.Service) *Service {
|
||||
return &Service{db: db, points: pointsSvc}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
UserEmail string `json:"user_email"`
|
||||
PartnerID string `json:"partner_id,omitempty"`
|
||||
Amount int `json:"amount"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Student is a user record as seen by an administrator.
|
||||
type Student struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
StudentID string `json:"student_id,omitempty"`
|
||||
Balance int `json:"balance"`
|
||||
}
|
||||
|
||||
// Stats holds aggregated system metrics shown on the admin dashboard.
|
||||
type Stats struct {
|
||||
TotalStudents int `json:"total_students"`
|
||||
TotalPointsIssued int `json:"total_points_issued"`
|
||||
TotalPointsSpent int `json:"total_points_spent"`
|
||||
ActivePartners int `json:"active_partners"`
|
||||
}
|
||||
|
||||
// GrantPoints credits the given amount to the student's balance and records an
|
||||
// admin_grant transaction. Delegates to points.Service.EarnPoints so that all
|
||||
// balance mutation logic lives in one place.
|
||||
func (s *Service) GrantPoints(ctx context.Context, userID string, amount int, description string) error {
|
||||
return s.points.EarnPoints(ctx, points.EarnRequest{
|
||||
UserID: userID,
|
||||
Amount: amount,
|
||||
Type: "admin_grant",
|
||||
Description: description,
|
||||
})
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var total int
|
||||
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,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("service.ListTransactions: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var txs []AdminTransaction
|
||||
for rows.Next() {
|
||||
var t AdminTransaction
|
||||
if err := rows.Scan(&t.ID, &t.UserID, &t.UserEmail, &t.PartnerID,
|
||||
&t.Amount, &t.Type, &t.Description, &t.CreatedAt); err != nil {
|
||||
return nil, 0, fmt.Errorf("service.ListTransactions: scan: %w", err)
|
||||
}
|
||||
txs = append(txs, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, fmt.Errorf("service.ListTransactions: rows: %w", err)
|
||||
}
|
||||
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`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.ListStudents: %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)
|
||||
}
|
||||
students = append(students, st)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("service.ListStudents: rows: %w", err)
|
||||
}
|
||||
return students, nil
|
||||
}
|
||||
|
||||
// GetStats returns aggregated system statistics for the admin dashboard.
|
||||
func (s *Service) GetStats(ctx context.Context) (*Stats, error) {
|
||||
var stats Stats
|
||||
|
||||
// Single query for all transaction aggregates.
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0),
|
||||
COALESCE(ABS(SUM(amount) FILTER (WHERE amount < 0)), 0)
|
||||
FROM transactions`,
|
||||
).Scan(&stats.TotalPointsIssued, &stats.TotalPointsSpent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.GetStats: transaction aggregates: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE role = 'student'`,
|
||||
).Scan(&stats.TotalStudents)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.GetStats: total students: %w", err)
|
||||
}
|
||||
|
||||
err = s.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM partners WHERE is_active = true`,
|
||||
).Scan(&stats.ActivePartners)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.GetStats: active partners: %w", err)
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package auth handles user authentication: login and token refresh.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the auth domain.
|
||||
// It only parses requests and writes responses — no business logic here.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new auth Handler backed by the given service.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// loginRequest is the expected JSON body for POST /api/v1/auth/login.
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// refreshRequest is the expected JSON body for POST /api/v1/auth/refresh.
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// accessTokenResponse is the JSON body returned by a successful refresh.
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
// Accepts {"email": "...", "password": "..."}. Returns a token pair on success.
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Email == "" || req.Password == "" {
|
||||
response.Error(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Login(r.Context(), LoginRequest{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
slog.Error("handler.Login", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, pair)
|
||||
}
|
||||
|
||||
// Refresh handles POST /api/v1/auth/refresh.
|
||||
// Accepts {"refresh_token": "..."}. Returns a new access token on success.
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.RefreshToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
accessToken, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired refresh token")
|
||||
return
|
||||
}
|
||||
slog.Error("handler.Refresh", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, accessTokenResponse{AccessToken: accessToken})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims are the JWT payload fields used by this service.
|
||||
// Both access and refresh tokens use this struct; the Type field distinguishes them.
|
||||
// Every token includes a unique JWTID (jti) for future revocation support.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims // carries sub (user_id), exp, iat, jti
|
||||
Role string `json:"role,omitempty"` // populated only in access tokens
|
||||
Type string `json:"type"` // "access" or "refresh"
|
||||
}
|
||||
|
||||
// JWTManager generates and validates JWT tokens.
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
// NewJWTManager creates a JWTManager with the given HMAC secret and TTL durations.
|
||||
func NewJWTManager(secret string, accessTTL, refreshTTL time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secret: []byte(secret),
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAccessToken creates a signed HS256 access token for the given user.
|
||||
// Claims include: sub (user_id), role, jti (unique ID), iat, exp.
|
||||
func (m *JWTManager) GenerateAccessToken(userID, role string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateAccessToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.accessTTL)),
|
||||
},
|
||||
Role: role,
|
||||
Type: "access",
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateAccessToken: sign: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// GenerateRefreshToken creates a signed HS256 refresh token for the given user.
|
||||
// Claims include: sub (user_id), jti (unique ID), iat, exp.
|
||||
// The role is intentionally omitted — it is always re-fetched from the DB on use.
|
||||
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateRefreshToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.refreshTTL)),
|
||||
},
|
||||
Type: "refresh",
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(m.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jwt.GenerateRefreshToken: sign: %w", err)
|
||||
}
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
// ParseToken parses and cryptographically validates a JWT string.
|
||||
// It verifies the HMAC signature and token expiry but does NOT check the Type field —
|
||||
// callers are responsible for asserting the expected type ("access" or "refresh").
|
||||
func (m *JWTManager) ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("jwt.ParseToken: invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// newJTI generates a cryptographically random UUID v4 string for use as a JWT ID.
|
||||
func newJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Set version 4 and variant bits per RFC 4122
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested user does not exist in the database.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// UserRecord is the minimal user row fetched from the database during authentication.
|
||||
type UserRecord struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
}
|
||||
|
||||
// UserRepository defines the database operations the auth service depends on.
|
||||
// Defined as an interface so unit tests can inject a mock without a real database.
|
||||
type UserRepository interface {
|
||||
// GetUserByEmail returns the user row for the given email address.
|
||||
// Returns ErrNotFound if no user exists with that email.
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||
|
||||
// GetUserByID returns the user row for the given primary key.
|
||||
// Returns ErrNotFound if the user has been deleted since the token was issued.
|
||||
GetUserByID(ctx context.Context, id string) (*UserRecord, error)
|
||||
}
|
||||
|
||||
// Repository is the PostgreSQL-backed implementation of UserRepository.
|
||||
type Repository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new PostgreSQL-backed auth Repository.
|
||||
func NewRepository(db *pgxpool.Pool) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// GetUserByEmail fetches the user row needed for password verification.
|
||||
// Returns ErrNotFound if no user exists with that email.
|
||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||
var u UserRecord
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, role FROM users WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetUserByEmail: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserByID fetches the user row needed when refreshing a token.
|
||||
// The role is re-read from the DB so that admin role changes take effect on the next refresh.
|
||||
// Returns ErrNotFound if the user has been deleted since the token was issued.
|
||||
func (r *Repository) GetUserByID(ctx context.Context, id string) (*UserRecord, error) {
|
||||
var u UserRecord
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, email, password_hash, role FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetUserByID: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ErrInvalidCredentials is returned for both an unknown email and a wrong password.
|
||||
// Using a single sentinel prevents callers from distinguishing the two cases,
|
||||
// which would otherwise allow email enumeration.
|
||||
var ErrInvalidCredentials = errors.New("invalid email or password")
|
||||
|
||||
// Service contains business logic for authentication.
|
||||
// All password and token operations live here; the handler only parses HTTP.
|
||||
type Service struct {
|
||||
repo UserRepository
|
||||
jwt *JWTManager
|
||||
}
|
||||
|
||||
// NewService creates a new auth Service with the given repository and JWT manager.
|
||||
func NewService(repo UserRepository, jwt *JWTManager) *Service {
|
||||
return &Service{repo: repo, jwt: jwt}
|
||||
}
|
||||
|
||||
// LoginRequest holds credentials submitted by the user on the login form.
|
||||
type LoginRequest struct {
|
||||
Email string
|
||||
Password string
|
||||
}
|
||||
|
||||
// TokenPair holds the access and refresh tokens returned after a successful login.
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Login validates credentials and returns a JWT token pair on success.
|
||||
// Returns ErrInvalidCredentials for both an unknown email and a wrong password
|
||||
// so callers cannot distinguish between the two cases (anti-enumeration).
|
||||
func (s *Service) Login(ctx context.Context, req LoginRequest) (*TokenPair, error) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Run a dummy bcrypt comparison so that response time is constant
|
||||
// regardless of whether the email exists in the database.
|
||||
bcrypt.CompareHashAndPassword([]byte("$2a$10$dummyhashpadding000000000000000000000000000000000000000"), []byte(req.Password)) //nolint:errcheck
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, fmt.Errorf("service.Login: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
accessToken, err := s.jwt.GenerateAccessToken(user.ID, user.Role)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.Login: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := s.jwt.GenerateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.Login: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("user logged in", "user_id", user.ID, "role", user.Role)
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Refresh validates a refresh token and returns a new access token.
|
||||
// The user's role is re-fetched from the database so that role changes take effect immediately
|
||||
// rather than persisting until the old refresh token expires.
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (string, error) {
|
||||
claims, err := s.jwt.ParseToken(refreshToken)
|
||||
if err != nil {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
if claims.Type != "refresh" {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
user, err := s.repo.GetUserByID(ctx, claims.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return "", ErrInvalidCredentials
|
||||
}
|
||||
return "", fmt.Errorf("service.Refresh: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwt.GenerateAccessToken(user.ID, user.Role)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service.Refresh: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
// ValidateToken parses an access token and returns its claims.
|
||||
// Returns ErrInvalidCredentials if the token is invalid, expired, or not an access token.
|
||||
// Used by other services that need to inspect token claims (e.g. extracting user_id).
|
||||
func (s *Service) ValidateToken(token string) (*Claims, error) {
|
||||
claims, err := s.jwt.ParseToken(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service.ValidateToken: %w", err)
|
||||
}
|
||||
if claims.Type != "access" {
|
||||
return nil, fmt.Errorf("service.ValidateToken: %w", ErrInvalidCredentials)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cu-points/backend/internal/auth"
|
||||
)
|
||||
|
||||
// mockRepo is a test double for UserRepository.
|
||||
// Populate user and/or repoErr before each test case.
|
||||
type mockRepo struct {
|
||||
user *auth.UserRecord
|
||||
repoErr error
|
||||
}
|
||||
|
||||
func (m *mockRepo) GetUserByEmail(_ context.Context, _ string) (*auth.UserRecord, error) {
|
||||
return m.user, m.repoErr
|
||||
}
|
||||
|
||||
func (m *mockRepo) GetUserByID(_ context.Context, _ string) (*auth.UserRecord, error) {
|
||||
return m.user, m.repoErr
|
||||
}
|
||||
|
||||
// hashPassword hashes the given plain-text password using bcrypt minimum cost for speed.
|
||||
func hashPassword(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hashPassword: %v", err)
|
||||
}
|
||||
return string(h)
|
||||
}
|
||||
|
||||
// newTestService builds a Service wired to the given mock repo.
|
||||
func newTestService(repo auth.UserRepository) *auth.Service {
|
||||
jwtMgr := auth.NewJWTManager(
|
||||
"test-secret-minimum-32-characters-long",
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
return auth.NewService(repo, jwtMgr)
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "a5b66288-4a97-410b-9e30-a7cf61cdabab",
|
||||
Email: "student@cu.ru",
|
||||
PasswordHash: hashPassword(t, "password123"),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
svc := newTestService(repo)
|
||||
|
||||
pair, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "student@cu.ru",
|
||||
Password: "password123",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if pair.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_WrongPassword(t *testing.T) {
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "a5b66288-4a97-410b-9e30-a7cf61cdabab",
|
||||
Email: "student@cu.ru",
|
||||
PasswordHash: hashPassword(t, "password123"),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
svc := newTestService(repo)
|
||||
|
||||
_, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "student@cu.ru",
|
||||
Password: "wrongpassword",
|
||||
})
|
||||
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_UserNotFound(t *testing.T) {
|
||||
repo := &mockRepo{repoErr: auth.ErrNotFound}
|
||||
svc := newTestService(repo)
|
||||
|
||||
_, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "nobody@cu.ru",
|
||||
Password: "password123",
|
||||
})
|
||||
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package config loads all runtime configuration from environment variables.
|
||||
// All other packages must read settings through this package — no magic strings elsewhere.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application settings read from environment variables.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
RedisURL string
|
||||
JWTSecret string
|
||||
JWTAccessTTL time.Duration
|
||||
JWTRefreshTTL time.Duration
|
||||
Port string
|
||||
Env string
|
||||
}
|
||||
|
||||
// Load reads environment variables and returns a populated Config.
|
||||
// Returns an error if any required variable is missing or cannot be parsed.
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
Port: envOrDefault("PORT", "8080"),
|
||||
Env: envOrDefault("ENV", "development"),
|
||||
}
|
||||
|
||||
for _, req := range []struct{ name, val string }{
|
||||
{"DATABASE_URL", cfg.DatabaseURL},
|
||||
{"REDIS_URL", cfg.RedisURL},
|
||||
{"JWT_SECRET", cfg.JWTSecret},
|
||||
} {
|
||||
if req.val == "" {
|
||||
return nil, fmt.Errorf("config.Load: required env var %s is not set", req.name)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
cfg.JWTAccessTTL, err = time.ParseDuration(envOrDefault("JWT_ACCESS_TTL", "15m"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config.Load: invalid JWT_ACCESS_TTL: %w", err)
|
||||
}
|
||||
cfg.JWTRefreshTTL, err = time.ParseDuration(envOrDefault("JWT_REFRESH_TTL", "168h"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config.Load: invalid JWT_REFRESH_TTL: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package partners handles the public partner listing endpoint.
|
||||
package partners
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the partners domain.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new partners Handler.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/partners.
|
||||
// Returns all active partners. This endpoint is publicly accessible (no auth required).
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
partners, err := h.service.ListActive(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("handler.List", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
// Return an empty array rather than null when there are no partners.
|
||||
if partners == nil {
|
||||
partners = []Partner{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, partners)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package partners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested partner does not exist.
|
||||
var ErrNotFound = errors.New("partner not found")
|
||||
|
||||
// Repository handles database access for the partners domain.
|
||||
type Repository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new partners Repository.
|
||||
func NewRepository(db *pgxpool.Pool) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// ListActive fetches all partners where is_active = true, ordered alphabetically.
|
||||
func (r *Repository) ListActive(ctx context.Context) ([]Partner, error) {
|
||||
rows, err := r.db.Query(ctx,
|
||||
`SELECT id, name, address, max_spend_pct
|
||||
FROM partners
|
||||
WHERE is_active = true
|
||||
ORDER BY name`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository.ListActive: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ps []Partner
|
||||
for rows.Next() {
|
||||
var p Partner
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Address, &p.MaxSpendPct); err != nil {
|
||||
return nil, fmt.Errorf("repository.ListActive: scan: %w", err)
|
||||
}
|
||||
ps = append(ps, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("repository.ListActive: rows: %w", err)
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
// GetByID fetches a single partner by its primary key.
|
||||
// Returns ErrNotFound if no partner exists with that ID.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Partner, error) {
|
||||
var p Partner
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, name, address, max_spend_pct
|
||||
FROM partners WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&p.ID, &p.Name, &p.Address, &p.MaxSpendPct)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetByID: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetByUserID fetches the partner record associated with the given cashier user account.
|
||||
// Returns ErrNotFound if no partner is linked to that user.
|
||||
func (r *Repository) GetByUserID(ctx context.Context, userID string) (*Partner, error) {
|
||||
var p Partner
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, name, address, max_spend_pct
|
||||
FROM partners WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&p.ID, &p.Name, &p.Address, &p.MaxSpendPct)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetByUserID: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package partners
|
||||
|
||||
import "context"
|
||||
|
||||
// Service handles business logic for the partners domain.
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewService creates a new partners Service.
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// Partner represents a participating business.
|
||||
type Partner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
MaxSpendPct int `json:"max_spend_pct"`
|
||||
}
|
||||
|
||||
// ListActive returns all partners with is_active = true.
|
||||
func (s *Service) ListActive(ctx context.Context) ([]Partner, error) {
|
||||
return s.repo.ListActive(ctx)
|
||||
}
|
||||
|
||||
// GetByID returns a partner by its primary key.
|
||||
// Returns ErrNotFound (from repository) if the partner does not exist.
|
||||
func (s *Service) GetByID(ctx context.Context, id string) (*Partner, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package points handles earning and spending of loyalty points.
|
||||
package points
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/cu-points/backend/internal/middleware"
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the points domain.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new points Handler.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// qrResponse is the JSON body returned by GenerateQR.
|
||||
type qrResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// spendRequest is the expected JSON body for POST /api/v1/partner/spend.
|
||||
type spendRequest struct {
|
||||
QRToken string `json:"qr_token"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
|
||||
// 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) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
token, err := h.service.GenerateQRToken(r.Context(), userID)
|
||||
if err != nil {
|
||||
slog.Error("handler.GenerateQR", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, qrResponse{Token: token})
|
||||
}
|
||||
|
||||
// Spend handles POST /api/v1/partner/spend.
|
||||
// Accepts {qr_token, amount}; debits student balance atomically.
|
||||
// Requires role=partner (enforced by the router's RequireRole middleware).
|
||||
func (h *Handler) Spend(w http.ResponseWriter, r *http.Request) {
|
||||
var req spendRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
response.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.QRToken == "" {
|
||||
response.Error(w, http.StatusBadRequest, "qr_token is required")
|
||||
return
|
||||
}
|
||||
if req.Amount <= 0 {
|
||||
response.Error(w, http.StatusBadRequest, "amount must be positive")
|
||||
return
|
||||
}
|
||||
|
||||
partnerID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
err := h.service.SpendPoints(r.Context(), SpendRequest{
|
||||
QRToken: req.QRToken,
|
||||
Amount: req.Amount,
|
||||
PartnerID: partnerID,
|
||||
})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidQRToken):
|
||||
response.Error(w, http.StatusUnauthorized, "invalid or expired QR token")
|
||||
case errors.Is(err, ErrQRAlreadyUsed):
|
||||
response.Error(w, http.StatusConflict, "QR token has already been used")
|
||||
case errors.Is(err, ErrInsufficientBalance):
|
||||
response.Error(w, http.StatusUnprocessableEntity, "insufficient balance")
|
||||
default:
|
||||
slog.Error("handler.Spend", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package points
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Repository defines the database operations needed by the points service.
|
||||
// Using an interface allows the service to be unit-tested with a mock implementation.
|
||||
type Repository interface {
|
||||
// GetBalance fetches the current balance for the given user.
|
||||
GetBalance(ctx context.Context, userID string) (int, error)
|
||||
// EarnAtomic credits amount to the user's balance and inserts a transaction row
|
||||
// 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
|
||||
}
|
||||
|
||||
// CacheClient defines the Redis operations needed by the points service.
|
||||
type CacheClient interface {
|
||||
// IsQRUsed returns true if the given jti has already been redeemed.
|
||||
IsQRUsed(ctx context.Context, jti string) (bool, error)
|
||||
// MarkQRUsed records the jti as used with a TTL of 5 minutes.
|
||||
MarkQRUsed(ctx context.Context, jti string) error
|
||||
}
|
||||
|
||||
// pgRepository is the PostgreSQL-backed implementation of Repository.
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new PostgreSQL-backed points Repository.
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
// GetBalance returns the current point balance for the given user.
|
||||
func (r *pgRepository) GetBalance(ctx context.Context, userID string) (int, error) {
|
||||
var balance int
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT balance FROM users WHERE id = $1`,
|
||||
userID,
|
||||
).Scan(&balance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("repository.GetBalance: %w", err)
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// EarnAtomic credits amount to the user's balance and records a transaction,
|
||||
// all within a single DB transaction.
|
||||
// txType must be a value accepted by the transactions.type CHECK constraint ("earn" or "admin_grant").
|
||||
func (r *pgRepository) EarnAtomic(ctx context.Context, userID string, amount int, txType, description string) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`UPDATE users SET balance = balance + $1 WHERE id = $2`,
|
||||
amount, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: update balance: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO transactions (user_id, amount, type, description)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
userID, amount, txType, description,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: insert transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("repository.EarnAtomic: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendAtomic deducts amount from the student's balance and records a spend
|
||||
// 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 {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var newBalance int
|
||||
err = tx.QueryRow(ctx,
|
||||
`UPDATE users SET balance = balance - $1 WHERE id = $2 RETURNING balance`,
|
||||
amount, userID,
|
||||
).Scan(&newBalance)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: update balance: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`INSERT INTO transactions (user_id, partner_id, amount, type)
|
||||
VALUES ($1, $2, $3, 'spend')`,
|
||||
userID, partnerID, -amount,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: insert transaction: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("repository.SpendAtomic: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// redisCache is the Redis-backed implementation of CacheClient.
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache creates a Redis-backed CacheClient for QR token one-time-use tracking.
|
||||
func NewRedisCache(client *redis.Client) CacheClient {
|
||||
return &redisCache{client: client}
|
||||
}
|
||||
|
||||
// IsQRUsed returns true if the given jti key already exists in Redis.
|
||||
func (c *redisCache) IsQRUsed(ctx context.Context, jti string) (bool, error) {
|
||||
count, err := c.client.Exists(ctx, "used_qr:"+jti).Result()
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// MarkQRUsed sets used_qr:<jti> = "1" with a 5-minute TTL.
|
||||
// TTL matches the QR token expiry so the key is automatically cleaned up.
|
||||
func (c *redisCache) MarkQRUsed(ctx context.Context, jti string) error {
|
||||
return c.client.Set(ctx, "used_qr:"+jti, "1", 5*time.Minute).Err()
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package points
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// ErrInsufficientBalance is returned when a student's balance is lower than the requested spend amount.
|
||||
var ErrInsufficientBalance = errors.New("insufficient balance")
|
||||
|
||||
// ErrQRAlreadyUsed is returned when a QR token has already been redeemed.
|
||||
var ErrQRAlreadyUsed = errors.New("QR token already used")
|
||||
|
||||
// ErrInvalidQRToken is returned when the QR JWT is malformed, expired, or has the wrong type.
|
||||
var ErrInvalidQRToken = errors.New("invalid or expired QR token")
|
||||
|
||||
const qrTokenTTL = 5 * time.Minute
|
||||
|
||||
// qrClaims are the JWT payload fields for one-time QR spend tokens.
|
||||
type qrClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Type string `json:"type"` // always "qr"
|
||||
}
|
||||
|
||||
// Service contains the critical business logic for points operations.
|
||||
// This is the most important file in the project — all balance mutations live here.
|
||||
type Service struct {
|
||||
repo Repository
|
||||
cache CacheClient
|
||||
secret []byte
|
||||
}
|
||||
|
||||
// NewService creates a new points Service.
|
||||
// secret must be the same HMAC secret used for all JWTs in this application.
|
||||
func NewService(repo Repository, cache CacheClient, secret string) *Service {
|
||||
return &Service{repo: repo, cache: cache, secret: []byte(secret)}
|
||||
}
|
||||
|
||||
// EarnRequest holds the data needed to credit a student's balance.
|
||||
// Type must be "earn" or "admin_grant" — enforced by the DB CHECK constraint.
|
||||
type EarnRequest struct {
|
||||
UserID string
|
||||
Amount int
|
||||
Type string // "earn" or "admin_grant"
|
||||
Description string
|
||||
}
|
||||
|
||||
// EarnPoints credits the given amount to the student's balance and records a
|
||||
// transaction of the specified type atomically. This is the single place where
|
||||
// all credit logic lives — admin grants, future LMS integrations, etc. must
|
||||
// call this method rather than touching the DB directly.
|
||||
func (s *Service) EarnPoints(ctx context.Context, req EarnRequest) error {
|
||||
if req.Amount <= 0 {
|
||||
return fmt.Errorf("service.EarnPoints: amount must be positive")
|
||||
}
|
||||
if err := s.repo.EarnAtomic(ctx, req.UserID, req.Amount, req.Type, req.Description); err != nil {
|
||||
return fmt.Errorf("service.EarnPoints: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpendRequest holds the data needed to debit a student's balance at a partner.
|
||||
type SpendRequest struct {
|
||||
QRToken string
|
||||
Amount int
|
||||
PartnerID string
|
||||
}
|
||||
|
||||
// SpendPoints debits the given amount from the student's balance
|
||||
// and records a spend transaction atomically in a single DB transaction.
|
||||
// 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 {
|
||||
// Step 1: validate QR JWT and extract student_id and jti.
|
||||
claims, err := s.parseQRToken(req.QRToken)
|
||||
if err != nil {
|
||||
return ErrInvalidQRToken
|
||||
}
|
||||
studentID := claims.Subject
|
||||
jti := claims.ID
|
||||
|
||||
// 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)
|
||||
}
|
||||
if used {
|
||||
return 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)
|
||||
}
|
||||
if balance < req.Amount {
|
||||
return 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 {
|
||||
// Propagate balance constraint violation with a domain error.
|
||||
if isConstraintError(err) {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
return 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 nil
|
||||
}
|
||||
|
||||
// GenerateQRToken creates a one-time JWT for the student to present at a partner terminal.
|
||||
// The token encodes the student's user_id and a unique jti; TTL is 5 minutes.
|
||||
func (s *Service) GenerateQRToken(ctx context.Context, userID string) (string, error) {
|
||||
jti, err := newJTI()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service.GenerateQRToken: generate jti: %w", err)
|
||||
}
|
||||
|
||||
claims := qrClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(qrTokenTTL)),
|
||||
},
|
||||
Type: "qr",
|
||||
}
|
||||
|
||||
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("service.GenerateQRToken: sign: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// parseQRToken validates the JWT signature/expiry and asserts type="qr".
|
||||
func (s *Service) parseQRToken(tokenStr string) (*qrClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &qrClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return s.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
claims, ok := token.Claims.(*qrClaims)
|
||||
if !ok || claims.Type != "qr" {
|
||||
return nil, fmt.Errorf("not a QR token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// newJTI generates a cryptographically random UUID v4 string for use as a JWT ID.
|
||||
func newJTI() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil
|
||||
}
|
||||
|
||||
// isConstraintError reports whether err contains a PostgreSQL balance CHECK violation.
|
||||
func isConstraintError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "check")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package users handles student profile and transaction history endpoints.
|
||||
package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/cu-points/backend/internal/middleware"
|
||||
"github.com/cu-points/backend/pkg/response"
|
||||
)
|
||||
|
||||
// Handler holds HTTP handler methods for the users domain.
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// NewHandler creates a new users Handler.
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// transactionsResponse is the JSON body returned by GET /me/transactions.
|
||||
type transactionsResponse struct {
|
||||
Transactions []Transaction `json:"transactions"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// Me handles GET /api/v1/me.
|
||||
// Returns the authenticated student's profile and current balance.
|
||||
// Requires role=student (enforced by the router's RequireRole middleware).
|
||||
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
profile, err := h.service.GetProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
response.Error(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
slog.Error("handler.Me", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON(w, http.StatusOK, profile)
|
||||
}
|
||||
|
||||
// Transactions handles GET /api/v1/me/transactions.
|
||||
// Returns paginated transaction history for the authenticated student.
|
||||
// Query params: limit (default 20, max 100), offset (default 0).
|
||||
// Response: { "transactions": [...], "total": N }
|
||||
func (h *Handler) Transactions(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
limit := 20
|
||||
offset := 0
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
|
||||
txs, total, err := h.service.GetTransactions(r.Context(), userID, limit, offset)
|
||||
if err != nil {
|
||||
slog.Error("handler.Transactions", "err", err)
|
||||
response.Error(w, http.StatusInternalServerError, "internal server error")
|
||||
return
|
||||
}
|
||||
// Return an empty array rather than null when there are no transactions.
|
||||
if txs == nil {
|
||||
txs = []Transaction{}
|
||||
}
|
||||
response.JSON(w, http.StatusOK, transactionsResponse{
|
||||
Transactions: txs,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested user does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Repository handles all database access for the users domain.
|
||||
type Repository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRepository creates a new users Repository.
|
||||
func NewRepository(db *pgxpool.Pool) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
// GetByID fetches a user's profile by primary key.
|
||||
// Returns ErrNotFound if no user exists with that ID.
|
||||
func (r *Repository) GetByID(ctx context.Context, id string) (*Profile, error) {
|
||||
var p Profile
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT id, email, name, COALESCE(student_id, ''), balance
|
||||
FROM users WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&p.ID, &p.Email, &p.Name, &p.StudentID, &p.Balance)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("repository.GetByID: %w", err)
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdateBalance adds delta to the user's balance within an existing pgx transaction.
|
||||
// delta is positive when earning points, negative when spending.
|
||||
// Returns the new balance after the update via RETURNING, so the service can include
|
||||
// it in the API response without a second query.
|
||||
// The database CHECK (balance >= 0) acts as the last line of defense against overdrafts;
|
||||
// this function will return an error if the constraint fires.
|
||||
func (r *Repository) UpdateBalance(ctx context.Context, tx pgx.Tx, id string, delta int) (int, error) {
|
||||
var newBalance int
|
||||
err := tx.QueryRow(ctx,
|
||||
`UPDATE users SET balance = balance + $1 WHERE id = $2 RETURNING balance`,
|
||||
delta, id,
|
||||
).Scan(&newBalance)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("repository.UpdateBalance: %w", err)
|
||||
}
|
||||
return newBalance, nil
|
||||
}
|
||||
|
||||
// CountTransactions returns the total number of transactions for the given user.
|
||||
// Used alongside ListTransactions to populate pagination metadata.
|
||||
func (r *Repository) CountTransactions(ctx context.Context, userID string) (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM transactions WHERE user_id = $1`,
|
||||
userID,
|
||||
).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("repository.CountTransactions: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListTransactions returns paginated transactions for the given user, ordered newest first.
|
||||
func (r *Repository) ListTransactions(ctx context.Context, userID string, limit, offset int) ([]Transaction, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id,
|
||||
amount,
|
||||
type,
|
||||
COALESCE(description, ''),
|
||||
COALESCE(partner_id::text, ''),
|
||||
created_at
|
||||
FROM transactions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
userID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repository.ListTransactions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var txs []Transaction
|
||||
for rows.Next() {
|
||||
var t Transaction
|
||||
if err := rows.Scan(&t.ID, &t.Amount, &t.Type, &t.Description, &t.PartnerID, &t.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("repository.ListTransactions: scan: %w", err)
|
||||
}
|
||||
txs = append(txs, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("repository.ListTransactions: rows: %w", err)
|
||||
}
|
||||
return txs, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package users
|
||||
|
||||
import "context"
|
||||
|
||||
// Service handles business logic for the users domain.
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
// NewService creates a new users Service.
|
||||
func NewService(repo *Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
// Profile represents a student's public profile and current balance.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
StudentID string `json:"student_id,omitempty"`
|
||||
Balance int `json:"balance"`
|
||||
}
|
||||
|
||||
// Transaction represents a single point-earning or point-spending event.
|
||||
type Transaction struct {
|
||||
ID string `json:"id"`
|
||||
Amount int `json:"amount"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
PartnerID string `json:"partner_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetProfile returns the profile and current balance for the given user.
|
||||
func (s *Service) GetProfile(ctx context.Context, userID string) (*Profile, error) {
|
||||
return s.repo.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetTransactions returns a paginated list of transactions for the given user
|
||||
// (newest first) together with the total row count for pagination metadata.
|
||||
func (s *Service) GetTransactions(ctx context.Context, userID string, limit, offset int) ([]Transaction, int, error) {
|
||||
total, err := s.repo.CountTransactions(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
txs, err := s.repo.ListTransactions(ctx, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return txs, total, nil
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// Package cache provides Redis client initialization.
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// NewClient creates and validates a Redis client using the given REDIS_URL.
|
||||
// Returns an error if the URL cannot be parsed or if the initial PING fails.
|
||||
func NewClient(ctx context.Context, redisURL string) (*redis.Client, error) {
|
||||
opts, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache.NewClient: parse URL: %w", err)
|
||||
}
|
||||
client := redis.NewClient(opts)
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, fmt.Errorf("cache.NewClient: ping: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package db provides PostgreSQL connection pool initialization.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewPool creates and validates a pgx connection pool using the given DATABASE_URL.
|
||||
// Returns an error if the pool cannot be created or if the initial ping fails.
|
||||
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db.NewPool: create pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("db.NewPool: ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package response provides helpers for writing consistent JSON API responses.
|
||||
// Every handler must use these helpers — never call json.Encode directly.
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type successBody struct {
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// JSON writes a successful JSON response with the given HTTP status code and data payload.
|
||||
// The payload is wrapped in {"data": ...} to match the API envelope convention.
|
||||
func JSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(successBody{Data: data}) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Error writes a JSON error response with the given HTTP status code and human-readable message.
|
||||
// The message is wrapped in {"error": ...}.
|
||||
func Error(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(errorBody{Error: message}) //nolint:errcheck
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: cupoints
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U dev -d cupoints"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres_test:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: cupoints_test
|
||||
POSTGRES_USER: dev
|
||||
POSTGRES_PASSWORD: dev
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- pgdata_test:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U dev -d cupoints_test"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata_test:
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Input, Card } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { TokenPair, Profile, UserRole } from '@/lib/types';
|
||||
|
||||
function parseJwtRole(token: string): UserRole | null {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
const padded = part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
|
||||
const decoded = JSON.parse(atob(padded.replace(/-/g, '+').replace(/_/g, '/'))) as {
|
||||
role?: string;
|
||||
};
|
||||
const role = decoded.role;
|
||||
if (role === 'student' || role === 'partner' || role === 'admin') return role;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_REDIRECT: Record<UserRole, string> = {
|
||||
student: '/dashboard',
|
||||
partner: '/scan',
|
||||
admin: '/admin/dashboard',
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { setTokens, setUser } = useAuthStore();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const tokens = await api.post<TokenPair>('/api/v1/auth/login', { email, password });
|
||||
|
||||
setTokens(tokens.access_token, tokens.refresh_token);
|
||||
|
||||
const profile = await api.get<Profile>('/api/v1/me');
|
||||
const role = parseJwtRole(tokens.access_token);
|
||||
if (!role) throw new Error('Не удалось определить роль пользователя');
|
||||
|
||||
setUser({ ...profile, role });
|
||||
router.push(ROLE_REDIRECT[role]);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка входа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4">
|
||||
<Card className="w-full max-w-sm">
|
||||
<h1 className="mb-6 text-2xl font-bold text-white">CU Points</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder="student@cu.ru"
|
||||
/>
|
||||
<Input
|
||||
label="Пароль"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" isLoading={loading} className="mt-2 w-full">
|
||||
Войти
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function ScanPage() {
|
||||
// TODO: QR scanner + amount input form → POST /api/v1/partner/spend → show result
|
||||
return (
|
||||
<main className="p-6">
|
||||
<p className="text-gray-500">Partner scan — coming soon</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BalanceCard } from '@/components/BalanceCard';
|
||||
import { TransactionList } from '@/components/TransactionList';
|
||||
import { Spinner } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import type { Profile, Transaction, PaginatedResponse } from '@/lib/types';
|
||||
|
||||
export default function StudentDashboardPage() {
|
||||
const { user, updateBalance } = useAuthStore();
|
||||
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [loadingProfile, setLoadingProfile] = useState(true);
|
||||
const [loadingTxs, setLoadingTxs] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [p, page] = await Promise.all([
|
||||
api.get<Profile>('/api/v1/me'),
|
||||
api.get<PaginatedResponse<Transaction>>('/api/v1/me/transactions?limit=5'),
|
||||
]);
|
||||
setProfile(p);
|
||||
updateBalance(p.balance);
|
||||
setTransactions(page.transactions);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoadingProfile(false);
|
||||
setLoadingTxs(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [updateBalance]);
|
||||
|
||||
if (loadingProfile) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = profile?.name ?? user?.name ?? '';
|
||||
const balance = profile?.balance ?? user?.balance ?? 0;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg space-y-6 p-4 pb-10 pt-6">
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
Привет, {displayName.split(' ')[0]} 👋
|
||||
</h1>
|
||||
|
||||
<BalanceCard balance={balance} name={displayName} />
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-gray-200">Последние операции</h2>
|
||||
<Link href="/history" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
Вся история →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-gray-800 p-4 ring-1 ring-gray-700">
|
||||
<TransactionList transactions={transactions} isLoading={loadingTxs} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { TransactionList } from '@/components/TransactionList';
|
||||
import { Button } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Transaction, PaginatedResponse } from '@/lib/types';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function HistoryPage() {
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPage = useCallback(async (currentOffset: number, append: boolean) => {
|
||||
if (append) setLoadingMore(true);
|
||||
else setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const page = await api.get<PaginatedResponse<Transaction>>(
|
||||
`/api/v1/me/transactions?limit=${PAGE_SIZE}&offset=${currentOffset}`,
|
||||
);
|
||||
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(() => {
|
||||
fetchPage(0, false);
|
||||
}, [fetchPage]);
|
||||
|
||||
function handleLoadMore() {
|
||||
const nextOffset = offset + PAGE_SIZE;
|
||||
setOffset(nextOffset);
|
||||
fetchPage(nextOffset, true);
|
||||
}
|
||||
|
||||
const hasMore = transactions.length < total;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white">История операций</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-4 rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl bg-gray-800 p-4 ring-1 ring-gray-700">
|
||||
<TransactionList transactions={transactions} isLoading={loading} />
|
||||
</div>
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="secondary" onClick={handleLoadMore} isLoading={loadingMore}>
|
||||
Загрузить ещё
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasMore && transactions.length > 0 && (
|
||||
<p className="mt-4 text-center text-xs text-gray-600">Это все операции</p>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PartnerCard } from '@/components/PartnerCard';
|
||||
import { Spinner } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Partner } from '@/lib/types';
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const data = await api.get<Partner[]>('/api/v1/partners');
|
||||
setPartners(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Ошибка загрузки партнёров');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg p-4 pb-10 pt-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold text-white">Партнёры</h1>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && partners.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-gray-500">Партнёры пока не добавлены</p>
|
||||
)}
|
||||
|
||||
{!loading && partners.length > 0 && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{partners.map((p) => (
|
||||
<PartnerCard key={p.id} partner={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { QRDisplay } from '@/components/QRDisplay';
|
||||
|
||||
export default function QRPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 p-6">
|
||||
<h1 className="text-xl font-bold text-white">Оплата поинтами</h1>
|
||||
|
||||
<QRDisplay />
|
||||
|
||||
<p className="max-w-xs text-center text-sm text-gray-500">
|
||||
Покажи этот QR кассиру. Он действителен 5 минут и может быть использован только один раз.
|
||||
</p>
|
||||
|
||||
<Link href="/dashboard" className="text-sm text-blue-400 hover:text-blue-300">
|
||||
← Назад
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function AdminDashboardPage() {
|
||||
// TODO(notion): fetch GET /api/v1/admin/stats → display key metrics + recent transactions
|
||||
return (
|
||||
<main className="p-6">
|
||||
<p className="text-gray-500">Admin dashboard — coming soon</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function GrantPage() {
|
||||
// TODO(notion): form (user_id, amount, description) → POST /api/v1/admin/points/grant → confirmation
|
||||
return (
|
||||
<main className="p-6">
|
||||
<p className="text-gray-500">Grant points — coming soon</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #0d0d14;
|
||||
--foreground: #f1f5f9;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import localFont from 'next/font/local';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = localFont({
|
||||
src: './fonts/GeistVF.woff',
|
||||
variable: '--font-geist-sans',
|
||||
weight: '100 900',
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: './fonts/GeistMonoVF.woff',
|
||||
variable: '--font-geist-mono',
|
||||
weight: '100 900',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CU Points',
|
||||
description: 'Система лояльности Центрального Университета',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function RootPage() {
|
||||
redirect('/login');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Card, Button } from '@/components/ui';
|
||||
import { formatPoints } from '@/lib/utils';
|
||||
|
||||
interface BalanceCardProps {
|
||||
balance: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function BalanceCard({ balance, name }: BalanceCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-blue-700 to-blue-900 text-white ring-0 shadow-lg">
|
||||
<p className="text-sm text-blue-200">{name}</p>
|
||||
<p className="mt-2 text-5xl font-bold tracking-tight">{formatPoints(balance)}</p>
|
||||
<p className="mt-1 text-sm text-blue-300">Доступно поинтов</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-5 bg-white/10 text-white hover:bg-white/20 border border-white/20"
|
||||
onClick={() => router.push('/qr')}
|
||||
>
|
||||
Показать QR
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Partner } from '@/lib/types';
|
||||
import { Card } from '@/components/ui';
|
||||
|
||||
interface PartnerCardProps {
|
||||
partner: Partner;
|
||||
}
|
||||
|
||||
export function PartnerCard({ partner }: PartnerCardProps) {
|
||||
return (
|
||||
<Card className="flex flex-col gap-1">
|
||||
<p className="font-semibold text-white">{partner.name}</p>
|
||||
<p className="text-sm text-gray-400">{partner.address}</p>
|
||||
<p className="mt-2 inline-flex items-center self-start rounded-full bg-blue-900/40 px-2.5 py-0.5 text-xs font-medium text-blue-300">
|
||||
До {partner.max_spend_pct}% поинтами
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Button, Spinner } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import type { QRResponse } from '@/lib/types';
|
||||
|
||||
const QR_TTL_SECONDS = 300; // 5 minutes, matches backend JWT TTL
|
||||
|
||||
export function QRDisplay() {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = useState(QR_TTL_SECONDS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchToken = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.get<QRResponse>('/api/v1/me/qr');
|
||||
setToken(data.token);
|
||||
setSecondsLeft(QR_TTL_SECONDS);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Не удалось получить QR-код');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, [fetchToken]);
|
||||
|
||||
// Countdown tick — stops at 0.
|
||||
useEffect(() => {
|
||||
if (!token || secondsLeft <= 0) return;
|
||||
const id = setInterval(() => setSecondsLeft((s) => s - 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [token, secondsLeft]);
|
||||
|
||||
const expired = secondsLeft <= 0;
|
||||
const minutes = Math.floor(secondsLeft / 60);
|
||||
const seconds = secondsLeft % 60;
|
||||
const countdownText = `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-gray-500">Генерируем QR-код…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-12">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<Button onClick={fetchToken}>Попробовать снова</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
{/* QR code always has a white bg — required for scanner contrast */}
|
||||
<div
|
||||
className={`rounded-2xl bg-white p-5 shadow-xl transition-opacity ${expired ? 'opacity-20' : ''}`}
|
||||
>
|
||||
{token && !expired ? (
|
||||
<QRCodeSVG value={token} size={220} level="H" includeMargin={false} />
|
||||
) : (
|
||||
<div className="flex h-[220px] w-[220px] items-center justify-center">
|
||||
<p className="text-sm text-gray-400">QR-код истёк</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!expired ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
Действителен ещё{' '}
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${secondsLeft < 60 ? 'text-red-400' : 'text-gray-200'}`}
|
||||
>
|
||||
{countdownText}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<Button onClick={fetchToken} isLoading={loading}>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Transaction } from '@/lib/types';
|
||||
import { Badge, Spinner } from '@/components/ui';
|
||||
import { formatDate, formatTransactionAmount } from '@/lib/utils';
|
||||
|
||||
interface TransactionListProps {
|
||||
transactions: Transaction[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function TransactionList({ transactions, isLoading }: TransactionListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-gray-500">Пока нет операций</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-gray-700">
|
||||
{transactions.map((tx) => (
|
||||
<li key={tx.id} className="flex items-center gap-3 py-3">
|
||||
<Badge type={tx.type} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-100">
|
||||
{tx.description || (tx.type === 'spend' ? 'Оплата у партнёра' : 'Начисление поинтов')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(tx.created_at)}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 text-sm font-semibold tabular-nums ${tx.amount >= 0 ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{formatTransactionAmount(tx.amount)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { TransactionType } from '@/lib/types';
|
||||
|
||||
const TYPE_LABELS: Record<TransactionType, string> = {
|
||||
earn: 'Начисление',
|
||||
spend: 'Списание',
|
||||
admin_grant: 'Начисление',
|
||||
expire: 'Сгорание',
|
||||
};
|
||||
|
||||
const TYPE_CLASSES: Record<TransactionType, string> = {
|
||||
earn: 'bg-green-900/50 text-green-400',
|
||||
spend: 'bg-red-900/50 text-red-400',
|
||||
admin_grant: 'bg-blue-900/50 text-blue-400',
|
||||
expire: 'bg-gray-700 text-gray-400',
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
type: TransactionType;
|
||||
}
|
||||
|
||||
export function Badge({ type }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${TYPE_CLASSES[type]}`}
|
||||
>
|
||||
{TYPE_LABELS[type]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type ButtonHTMLAttributes } from 'react';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-800 disabled:text-blue-400',
|
||||
secondary: 'bg-gray-700 text-gray-200 hover:bg-gray-600 disabled:opacity-50',
|
||||
ghost: 'bg-transparent text-blue-400 hover:bg-gray-700 disabled:opacity-50',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
disabled,
|
||||
children,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled ?? isLoading}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-900 ${VARIANT_CLASSES[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type HTMLAttributes } from 'react';
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', ...props }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-gray-800 p-6 shadow-sm ring-1 ring-gray-700 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type InputHTMLAttributes, useId } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = '', ...props }: InputProps) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor={id} className="text-sm font-medium text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={`rounded-lg border bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-500 outline-none transition-colors focus:ring-2 ${error ? 'border-red-500 focus:border-red-500 focus:ring-red-800' : 'border-gray-600 focus:border-blue-500 focus:ring-blue-900'} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-8 w-8 border-2',
|
||||
lg: 'h-12 w-12 border-4',
|
||||
};
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label="Загрузка"
|
||||
className={`animate-spin rounded-full border-blue-600 border-t-transparent ${SIZE_CLASSES[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { Card } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export { Spinner } from './Spinner';
|
||||
@@ -0,0 +1,113 @@
|
||||
// All API calls must go through this module — never call fetch directly in components.
|
||||
// Automatically attaches the Bearer token and handles 401 → token refresh → retry.
|
||||
|
||||
import type { ApiError, ApiResponse } from './types';
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
function getAccessToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('access_token');
|
||||
}
|
||||
|
||||
function getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('refresh_token');
|
||||
}
|
||||
|
||||
/** Attempts to refresh the access token using the stored refresh token.
|
||||
* On success: updates localStorage + cookie and returns the new token.
|
||||
* On failure: returns null so the caller can redirect to /login. */
|
||||
async function tryRefresh(): Promise<string | null> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
const json = (await res.json()) as ApiResponse<{ access_token: string }>;
|
||||
const newToken = json.data.access_token;
|
||||
|
||||
// Sync to localStorage + cookie so the middleware cookie stays valid.
|
||||
localStorage.setItem('access_token', newToken);
|
||||
document.cookie = `access_token=${newToken}; path=/; SameSite=Strict; max-age=900`;
|
||||
|
||||
// Also patch the Zustand persist entry so the store stays consistent after page reload.
|
||||
try {
|
||||
const raw = localStorage.getItem('cu-points-auth');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { state?: { accessToken?: string } };
|
||||
if (parsed.state) {
|
||||
parsed.state.accessToken = newToken;
|
||||
localStorage.setItem('cu-points-auth', JSON.stringify(parsed));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If patching the store fails it's not critical — the next setTokens call will fix it.
|
||||
}
|
||||
|
||||
return newToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
isRetry = false,
|
||||
): Promise<T> {
|
||||
const token = getAccessToken();
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
// 401: attempt refresh once, then give up and redirect to login.
|
||||
if (res.status === 401 && !isRetry) {
|
||||
const newToken = await tryRefresh();
|
||||
if (newToken) {
|
||||
return request<T>(path, options, true);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw new Error('Session expired. Redirecting to login.');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const err = (await res.json()) as ApiError;
|
||||
message = err.error ?? message;
|
||||
} catch {
|
||||
// response body was not JSON
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as ApiResponse<T>;
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path, { method: 'GET' }),
|
||||
post: <T>(path: string, body: unknown) => request<T>(path, { method: 'POST', body }),
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
// Global client state managed with Zustand.
|
||||
// Only truly global state lives here: auth tokens and the current user profile.
|
||||
// Local UI state (loading flags, form values) stays in component useState.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from './types';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
setTokens: (access: string, refresh: string) => void;
|
||||
setUser: (user: User) => void;
|
||||
updateBalance: (newBalance: number) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
/** Writes the access token to localStorage and to a short-lived cookie so
|
||||
* Next.js middleware (edge runtime) can read it for role-based redirects. */
|
||||
function persistToken(accessToken: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
// 15 min lifetime matches the default JWT_ACCESS_TTL
|
||||
document.cookie = `access_token=${accessToken}; path=/; SameSite=Strict; max-age=900`;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
|
||||
setTokens: (accessToken, refreshToken) => {
|
||||
persistToken(accessToken);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
set({ accessToken, refreshToken });
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
|
||||
updateBalance: (newBalance) => {
|
||||
const { user } = get();
|
||||
if (user) set({ user: { ...user, balance: newBalance } });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
// Clear the auth cookie so middleware stops treating this session as logged in.
|
||||
document.cookie = 'access_token=; path=/; max-age=0';
|
||||
}
|
||||
set({ accessToken: null, refreshToken: null, user: null });
|
||||
},
|
||||
}),
|
||||
{ name: 'cu-points-auth' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
// All API response types live here. Never use `any` — add a proper type instead.
|
||||
// Field names match the backend JSON exactly (snake_case) so no conversion layer is needed.
|
||||
|
||||
export type TransactionType = 'earn' | 'spend' | 'admin_grant' | 'expire';
|
||||
export type UserRole = 'student' | 'partner' | 'admin';
|
||||
|
||||
// Profile returned by GET /api/v1/me
|
||||
export interface Profile {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
student_id: string;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
// User stored in the Zustand auth store: Profile + role extracted from JWT claims.
|
||||
export interface User extends Profile {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
description: string;
|
||||
partner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Partner {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
max_spend_pct: number;
|
||||
}
|
||||
|
||||
// Token pair returned by POST /api/v1/auth/login
|
||||
export interface TokenPair {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
// Response from GET /api/v1/me/qr
|
||||
export interface QRResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Stats returned by GET /api/v1/admin/stats
|
||||
export interface Stats {
|
||||
total_students: number;
|
||||
total_points_issued: number;
|
||||
total_points_spent: number;
|
||||
active_partners: number;
|
||||
}
|
||||
|
||||
// Generic success envelope: every API response is wrapped in { "data": ... }
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
// Shape of paginated transaction endpoints (both /me/transactions and /admin/transactions)
|
||||
export interface PaginatedResponse<T> {
|
||||
transactions: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Error envelope: { "error": "..." }
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Utility helpers for formatting numbers and dates throughout the UI.
|
||||
|
||||
/**
|
||||
* Formats a point balance for display: 1234 → "1 234 pts"
|
||||
* Uses the Russian locale so thousands are separated by a space.
|
||||
*/
|
||||
export function formatPoints(n: number): string {
|
||||
return `${new Intl.NumberFormat('ru-RU').format(n)} pts`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a transaction amount with a sign prefix.
|
||||
* Positive amounts use a "+" prefix; negative amounts use the Unicode minus sign "−".
|
||||
* Example: 50 → "+50", -30 → "−30"
|
||||
*/
|
||||
export function formatTransactionAmount(amount: number): string {
|
||||
if (amount >= 0) return `+${amount}`;
|
||||
return `−${Math.abs(amount)}`; // U+2212 MINUS SIGN, visually distinct from hyphen
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a UTC ISO timestamp to a compact Russian date+time string.
|
||||
* Example: "2024-04-28T14:32:00Z" → "28 апр., 14:32"
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
// Decodes a JWT payload without verifying the signature.
|
||||
// Safe for middleware because we only need the role for redirects — the backend
|
||||
// will reject any tampered token when the actual API call is made.
|
||||
function getJwtPayload(token: string): { role?: string; exp?: number } | null {
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return null;
|
||||
// Fix base64url → base64 padding
|
||||
const padded = part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
|
||||
const decoded = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return JSON.parse(decoded) as { role?: string; exp?: number };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Maps URL path prefixes to the role required to access them.
|
||||
const ROLE_REQUIREMENTS: [RegExp, string][] = [
|
||||
[/^\/(dashboard|history|qr|partners)/, 'student'],
|
||||
[/^\/scan/, 'partner'],
|
||||
[/^\/admin/, 'admin'],
|
||||
];
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const required = ROLE_REQUIREMENTS.find(([re]) => re.test(pathname))?.[1];
|
||||
if (!required) return NextResponse.next();
|
||||
|
||||
const token = request.cookies.get('access_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
const payload = getJwtPayload(token);
|
||||
|
||||
// Redirect if the token is missing, expired, or has the wrong role.
|
||||
if (!payload || payload.role !== required) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
if (payload.exp !== undefined && payload.exp * 1000 < Date.now()) {
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/(dashboard|history|qr|partners|scan|admin)(.*)'],
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6102
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.35",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.35",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
-- student_id is NULL for partner and admin accounts
|
||||
student_id TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('student', 'partner', 'admin')),
|
||||
-- balance is denormalised for read performance; always updated atomically with transactions table
|
||||
balance INTEGER NOT NULL DEFAULT 0 CHECK (balance >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON users(email);
|
||||
CREATE INDEX ON users(role);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE partners (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- user_id references the cashier/partner account in users table
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
-- max_spend_pct: maximum percentage of a purchase total that can be paid with points
|
||||
max_spend_pct INTEGER NOT NULL DEFAULT 50 CHECK (max_spend_pct BETWEEN 1 AND 100),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON partners(is_active);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS partners;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- Append-only ledger of all point movements. Never delete rows from this table.
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
-- partner_id is NULL for earn and admin_grant transactions
|
||||
partner_id UUID REFERENCES partners(id),
|
||||
-- amount > 0 means earn/grant; amount < 0 means spend/expire
|
||||
amount INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('earn', 'spend', 'admin_grant', 'expire')),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Primary query pattern: a student's history ordered by time
|
||||
CREATE INDEX ON transactions(user_id, created_at DESC);
|
||||
-- Secondary pattern: a partner's redemption history
|
||||
CREATE INDEX ON transactions(partner_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS transactions;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- Configurable rules that determine how many points a student earns per trigger event.
|
||||
-- Managed by administrators through the admin dashboard.
|
||||
CREATE TABLE earning_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
points_amount INTEGER NOT NULL CHECK (points_amount > 0),
|
||||
-- trigger_type matches the source system or event that initiates earning
|
||||
trigger_type TEXT NOT NULL CHECK (trigger_type IN ('attendance', 'assignment', 'referral', 'admin')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX ON earning_rules(trigger_type, is_active);
|
||||
|
||||
-- Seed default admin-grant rule so administrators can grant points immediately after deploy
|
||||
INSERT INTO earning_rules (name, points_amount, trigger_type, is_active)
|
||||
VALUES ('Manual admin grant', 1, 'admin', TRUE);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS earning_rules;
|
||||
Reference in New Issue
Block a user