feat: initial commit — backend API + student cabinet frontend

- Go backend: auth (JWT), points earn/spend, QR token generation,
  partners, admin grant/stats endpoints with chi router
- Next.js 14 frontend: login, student dashboard, transaction history,
  QR display, partners list
- PostgreSQL migrations (4 tables), Redis cache, Docker Compose
- CORS middleware, role-based route protection, Zustand auth store

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-01 10:03:27 +03:00
co-authored by Claude Sonnet 4.6
commit 50b3c4198a
80 changed files with 10579 additions and 0 deletions
+12
View File
@@ -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
+88
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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
View File
@@ -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-макеты от дизайнера до начала разработки фронтенда.
+358
View File
@@ -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: дашборд администратора
+45
View File
@@ -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 .
+8
View File
@@ -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
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/go-chi/chi/v5"
"github.com/cu-points/backend/internal/admin"
"github.com/cu-points/backend/internal/auth"
"github.com/cu-points/backend/internal/config"
"github.com/cu-points/backend/internal/middleware"
"github.com/cu-points/backend/internal/partners"
"github.com/cu-points/backend/internal/points"
"github.com/cu-points/backend/internal/users"
cachepkg "github.com/cu-points/backend/pkg/cache"
dbpkg "github.com/cu-points/backend/pkg/db"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cfg, err := config.Load()
if err != nil {
slog.Error("config load failed", "err", err)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
db, err := dbpkg.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("postgres connect failed", "err", err)
os.Exit(1)
}
defer db.Close()
redisClient, err := cachepkg.NewClient(ctx, cfg.RedisURL)
if err != nil {
slog.Error("redis connect failed", "err", err)
os.Exit(1)
}
defer redisClient.Close()
slog.Info("infrastructure connected", "postgres", cfg.DatabaseURL, "redis", cfg.RedisURL)
// ── Auth ──────────────────────────────────────────────────────────────────
jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL)
authRepo := auth.NewRepository(db)
authSvc := auth.NewService(authRepo, jwtManager)
authHandler := auth.NewHandler(authSvc)
// ── Users ─────────────────────────────────────────────────────────────────
usersRepo := users.NewRepository(db)
usersSvc := users.NewService(usersRepo)
usersHandler := users.NewHandler(usersSvc)
// ── Partners ──────────────────────────────────────────────────────────────
partnersRepo := partners.NewRepository(db)
partnersSvc := partners.NewService(partnersRepo)
partnersHandler := partners.NewHandler(partnersSvc)
// ── Points ────────────────────────────────────────────────────────────────
pointsRepo := points.NewRepository(db)
pointsCache := points.NewRedisCache(redisClient)
// pointsSvc is also passed to adminSvc so GrantPoints reuses EarnPoints logic.
pointsSvc := points.NewService(pointsRepo, pointsCache, cfg.JWTSecret)
pointsHandler := points.NewHandler(pointsSvc)
// ── Admin ─────────────────────────────────────────────────────────────────
adminSvc := admin.NewService(db, pointsSvc)
adminHandler := admin.NewHandler(adminSvc)
// ── Router ────────────────────────────────────────────────────────────────
r := chi.NewRouter()
r.Use(middleware.CORS)
r.Use(middleware.Logger)
r.Route("/api/v1", func(r chi.Router) {
// Public — no authentication required.
r.Post("/auth/login", authHandler.Login)
r.Post("/auth/refresh", authHandler.Refresh)
r.Get("/partners", partnersHandler.List)
// Any authenticated user — profile endpoint used by the login flow for all roles.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Get("/me", usersHandler.Me)
})
// Student-only endpoints.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("student"))
r.Get("/me/transactions", usersHandler.Transactions)
r.Get("/me/qr", pointsHandler.GenerateQR)
})
// Partner-only endpoints.
// Rate-limited to 10 spend requests per minute per partner to prevent abuse.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("partner"))
r.Use(middleware.SpendRateLimit(redisClient, 10))
r.Post("/partner/spend", pointsHandler.Spend)
})
// Admin-only endpoints.
r.Group(func(r chi.Router) {
r.Use(middleware.Auth(cfg.JWTSecret))
r.Use(middleware.RequireRole("admin"))
r.Post("/admin/points/grant", adminHandler.GrantPoints)
r.Get("/admin/transactions", adminHandler.ListTransactions)
r.Get("/admin/users", adminHandler.ListUsers)
r.Get("/admin/stats", adminHandler.Stats)
})
})
// Health check — no auth required.
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"status":"ok"}`)
})
addr := ":" + cfg.Port
slog.Info("server starting", "addr", addr)
if err := http.ListenAndServe(addr, r); err != nil {
slog.Error("server failed", "err", err)
os.Exit(1)
}
}
+84
View File
@@ -0,0 +1,84 @@
// seed inserts development test users into the database.
// Run via: make seed (only use against local dev DB, never production)
package main
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
)
type seedUser struct {
email string
name string
password string
role string
studentID string // empty string → NULL in DB
}
var devUsers = []seedUser{
{
email: "student@cu.ru",
name: "Иван Студентов",
password: "password123",
role: "student",
studentID: "STU001",
},
{
email: "partner@cu.ru",
name: "Кофейня Уют",
password: "password123",
role: "partner",
},
{
email: "admin@cu.ru",
name: "Администратор ЦУ",
password: "password123",
role: "admin",
},
}
func main() {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
slog.Error("DATABASE_URL is not set")
os.Exit(1)
}
ctx := context.Background()
db, err := pgxpool.New(ctx, dbURL)
if err != nil {
slog.Error("connect failed", "err", err)
os.Exit(1)
}
defer db.Close()
for _, u := range devUsers {
hash, err := bcrypt.GenerateFromPassword([]byte(u.password), bcrypt.DefaultCost)
if err != nil {
slog.Error("bcrypt failed", "email", u.email, "err", err)
os.Exit(1)
}
// NULLIF converts empty string to NULL for student_id
_, err = db.Exec(ctx, `
INSERT INTO users (email, name, password_hash, role, student_id)
VALUES ($1, $2, $3, $4, NULLIF($5, ''))
ON CONFLICT (email) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
name = EXCLUDED.name
`, u.email, u.name, string(hash), u.role, u.studentID)
if err != nil {
slog.Error("seed insert failed", "email", u.email, "err", err)
os.Exit(1)
}
fmt.Printf("seeded: %-30s role=%-8s password=%s\n", u.email, u.role, u.password)
}
fmt.Println("\nDone. Test credentials above are for local development only.")
}
+21
View File
@@ -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
)
+42
View File
@@ -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=
+121
View File
@@ -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)
}
+167
View File
@@ -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
}
+95
View File
@@ -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})
}
+117
View File
@@ -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
}
+78
View File
@@ -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
}
+118
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+61
View File
@@ -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
}
+96
View File
@@ -0,0 +1,96 @@
// Package middleware provides HTTP middleware: JWT verification, role guard, request logging.
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// contextKey is an unexported type for context keys in this package,
// preventing collisions with keys set by other packages.
type contextKey string
const (
userIDKey contextKey = "user_id"
userRoleKey contextKey = "user_role"
jtiKey contextKey = "jti"
)
// tokenClaims mirrors the JWT payload fields the middleware needs to inspect.
// Defined locally so the middleware does not import the auth package.
type tokenClaims struct {
jwt.RegisteredClaims // provides Subject (user_id), JWTID (jti), ExpiresAt
Role string `json:"role"`
Type string `json:"type"`
}
// UserIDFromContext retrieves the authenticated user's ID stored by Auth middleware.
func UserIDFromContext(ctx context.Context) string {
v, _ := ctx.Value(userIDKey).(string)
return v
}
// UserRoleFromContext retrieves the authenticated user's role stored by Auth middleware.
func UserRoleFromContext(ctx context.Context) string {
v, _ := ctx.Value(userRoleKey).(string)
return v
}
// JTIFromContext retrieves the JWT ID (jti) stored by Auth middleware.
// Useful for token revocation checks in downstream handlers.
func JTIFromContext(ctx context.Context) string {
v, _ := ctx.Value(jtiKey).(string)
return v
}
// Auth returns middleware that validates the Bearer JWT in the Authorization header.
// On success it injects user_id, role, and jti into the request context.
// Rejects tokens that are expired, have a bad signature, or are not of type "access"
// (prevents refresh tokens from being used on protected endpoints).
func Auth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr, err := bearerToken(r)
if err != nil {
http.Error(w, `{"error":"missing or invalid Authorization header"}`, http.StatusUnauthorized)
return
}
claims := &tokenClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("middleware.Auth: unexpected signing method: %v", t.Header["alg"])
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
// Explicitly block refresh tokens from reaching protected endpoints.
if claims.Type != "access" {
http.Error(w, `{"error":"access token required"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
ctx = context.WithValue(ctx, userRoleKey, claims.Role)
ctx = context.WithValue(ctx, jtiKey, claims.ID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// bearerToken extracts the token string from the Authorization: Bearer <token> header.
func bearerToken(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
return "", fmt.Errorf("middleware.bearerToken: missing Bearer prefix")
}
return strings.TrimPrefix(h, "Bearer "), nil
}
+21
View File
@@ -0,0 +1,21 @@
package middleware
import "net/http"
// CORS adds permissive CORS headers for local development.
// Handles the browser preflight OPTIONS request so chi doesn't return 405.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
+34
View File
@@ -0,0 +1,34 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
// Logger is structured request-logging middleware using log/slog.
// It records method, path, status code, and response duration for every request.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
+47
View File
@@ -0,0 +1,47 @@
package middleware
import (
"context"
"net/http"
"time"
"github.com/redis/go-redis/v9"
)
// SpendRateLimit returns middleware that limits requests to maxPerMinute per
// authenticated partner (identified by user_id in context). It must be placed
// after Auth + RequireRole("partner") so that UserIDFromContext is populated.
//
// Implementation: Redis INCR + EXPIRE sliding-window counter.
// Key: rate_limit:spend:<partner_id> — expires after 1 minute.
// On Redis failure the middleware fails open (lets the request through) so that
// a Redis outage does not take down point transactions.
func SpendRateLimit(rdb *redis.Client, maxPerMinute int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
partnerID := UserIDFromContext(r.Context())
key := "rate_limit:spend:" + partnerID
// Use a short-lived context for Redis so a slow Redis doesn't stall the request.
rCtx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
count, err := rdb.Incr(rCtx, key).Result()
if err != nil {
// Fail open: Redis unavailable should not block transactions.
next.ServeHTTP(w, r)
return
}
// Set the expiry only on the first increment so the window resets each minute.
if count == 1 {
rdb.Expire(rCtx, key, time.Minute) //nolint:errcheck
}
if count > int64(maxPerMinute) {
http.Error(w, `{"error":"rate limit exceeded, max 10 requests per minute"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
+22
View File
@@ -0,0 +1,22 @@
package middleware
import "net/http"
// RequireRole returns middleware that allows only requests whose authenticated user
// holds one of the permitted roles. Must be chained after Auth middleware.
func RequireRole(allowed ...string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, r := range allowed {
set[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := set[UserRoleFromContext(r.Context())]; !ok {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
+35
View File
@@ -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)
}
+86
View File
@@ -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
}
+32
View File
@@ -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)
}
+91
View File
@@ -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"})
}
+145
View File
@@ -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()
}
+185
View File
@@ -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 45: 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")
}
+84
View File
@@ -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,
})
}
+107
View File
@@ -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
}
+51
View File
@@ -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
}
+23
View File
@@ -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
}
+22
View File
@@ -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
}
+32
View File
@@ -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
}
+48
View File
@@ -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:
+3
View File
@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}
+36
View File
@@ -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
+36
View File
@@ -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.
+98
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+8
View File
@@ -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.
+20
View File
@@ -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;
}
}
+29
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function RootPage() {
redirect('/login');
}
+29
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+96
View File
@@ -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>
);
}
+45
View File
@@ -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>
);
}
+29
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+16
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+20
View File
@@ -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}`}
/>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';
export { Badge } from './Badge';
export { Spinner } from './Spinner';
+113
View File
@@ -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 }),
};
+62
View File
@@ -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' }
)
);
+70
View File
@@ -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;
}
+32
View File
@@ -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));
}
+53
View File
@@ -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)(.*)'],
};
+4
View File
@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
+6102
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
+19
View File
@@ -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;
+26
View File
@@ -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"]
}
+19
View File
@@ -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;
+17
View File
@@ -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;
+21
View File
@@ -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;
+21
View File
@@ -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;