From 62519081e7b478dcfbe1d0a8bfa016dda8249c6b Mon Sep 17 00:00:00 2001 From: Ilya Date: Mon, 25 May 2026 22:43:37 +0300 Subject: [PATCH] init: learning-service skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Микросервис обучения портала: тесты, курсы, видео-уроки, доступы, public-ссылки для кандидатов с email-валидацией. В этой итерации: - Skeleton (config, migrate, main, health) по паттерну tasks/candidates - Migration 001_init: 10 таблиц (tests/questions/answers/attempts/ attempt_answers + courses/lessons/lesson_progress + access_grants + public_tokens) с подробными комментариями why - Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и explanation скрываются в выдаче - Заглушки 501 для attempts / courses / lessons / video-stream / access / public-tokens — следующие итерации - k8s: namespace, configmap, secrets, postgres, deployment с HPA, service с portal-discovery annotations - Dockerfile, Makefile, .gitignore См. README.md для полного списка отложенного и инструкций запуска. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 8 + Dockerfile.server | 15 ++ Makefile | 16 ++ README.md | 100 ++++++++++++ cmd/server/main.go | 133 ++++++++++++++++ go.mod | 21 +++ go.sum | 32 ++++ internal/config/config.go | 97 ++++++++++++ internal/handler/health.go | 34 ++++ internal/handler/helpers.go | 63 ++++++++ internal/handler/test.go | 260 ++++++++++++++++++++++++++++++ internal/migrate/migrate.go | 58 +++++++ internal/model/model.go | 249 +++++++++++++++++++++++++++++ internal/repository/errors.go | 11 ++ internal/repository/test.go | 282 +++++++++++++++++++++++++++++++++ k8s/configmap.yaml | 22 +++ k8s/kustomization.yaml | 12 ++ k8s/namespace.yaml | 4 + k8s/postgres.yaml | 65 ++++++++ k8s/secrets.yaml | 26 +++ k8s/server-deployment.yaml | 87 ++++++++++ k8s/server-service.yaml | 18 +++ migrations/001_init.down.sql | 12 ++ migrations/001_init.up.sql | 290 ++++++++++++++++++++++++++++++++++ 24 files changed, 1915 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile.server create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/helpers.go create mode 100644 internal/handler/test.go create mode 100644 internal/migrate/migrate.go create mode 100644 internal/model/model.go create mode 100644 internal/repository/errors.go create mode 100644 internal/repository/test.go create mode 100644 k8s/configmap.yaml create mode 100644 k8s/kustomization.yaml create mode 100644 k8s/namespace.yaml create mode 100644 k8s/postgres.yaml create mode 100644 k8s/secrets.yaml create mode 100644 k8s/server-deployment.yaml create mode 100644 k8s/server-service.yaml create mode 100644 migrations/001_init.down.sql create mode 100644 migrations/001_init.up.sql diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9e19ab1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +/server +*.exe +tmp/ +vendor/ + +# macOS Finder metadata +.DS_Store diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..eddb73b --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,15 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src +# GOPRIVATE — Go должен фетчить portal-common напрямую, минуя proxy.golang.org. +ENV GOPRIVATE=gitea.estateliga.work +RUN apk add --no-cache git +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /learning-server ./cmd/server + +FROM gcr.io/distroless/static-debian12 +COPY --from=build /learning-server /learning-server +COPY migrations /migrations +EXPOSE 3001 +ENTRYPOINT ["/learning-server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eb138fc --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: build run test docker tidy + +build: + go build -o bin/learning-server ./cmd/server + +run: + go run ./cmd/server + +test: + go test ./... + +tidy: + go mod tidy + +docker: + docker build -f Dockerfile.server -t learning-server:dev . diff --git a/README.md b/README.md new file mode 100644 index 0000000..8409bae --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# learning-service + +Микросервис обучения портала: тесты, курсы с видео-уроками, +гранулярные доступы, public-ссылки для кандидатов с email-валидацией. + +## Стек + +- Go 1.25, chi/v5, pgx/v5, portal-common +- PostgreSQL (отдельная БД `learning`) +- MinIO (bucket `learning-videos`) — стрим-прокси, MinIO URL не светится + клиенту (по аналогии с telephony record stream) +- Redis (eventbus, опционально) — публикация событий + `test.submitted` / `course.enrolled` для portal-real-time +- k8s (namespace `learning`) + +## Сущности (см. `migrations/001_init.up.sql`) + +| Сущность | Назначение | +|---|---| +| `tests` | Тесты: title, passing_score, max_attempts, time_limit_sec, is_published, owner | +| `test_questions` | Вопросы (single / multi / text), points, explanation | +| `test_answers` | Варианты ответа с is_correct | +| `test_attempts` | Попытки прохождения: user_id ИЛИ public_token_id; score/passed | +| `test_attempt_answers` | Ответы пользователя в попытке (JSONB payload) | +| `courses` | Курсы: title, slug, cover, is_published | +| `lessons` | Уроки курса: markdown + video_key + опциональный test_id | +| `lesson_progress` | Прогресс «просмотрено / завершено» по урокам | +| `access_grants` | Гранулярный ACL: ресурс × subject (user/role/department/position/public) | +| `public_tokens` | Одноразовые ссылки кандидатам с intended_email + max_attempts | + +## Permission-модель (на стороне portal-backend) + +| Permission | Кому | Что даёт | +|---|---|---| +| `service.learning.access` | Все активные сотрудники | Видимость раздела, прохождение назначенного | +| `service.learning.author` | HR, тренеры, лиды | Создание/редактирование своих материалов, public-ссылки | +| `service.learning.admin` | Admin | Глобальный доступ к чужим материалам, аналитика | + +См. portal-backend `migrations/049_learning_permissions.up.sql`. + +## Что реализовано в этой итерации + +**Backend:** +- Skeleton сервиса (config, migrate, main, health) +- Migration `001_init.up.sql` — полная схема всех 10 таблиц +- **Tests:** полный CRUD + вопросы/ответы + scoped-выдача (не-владельцу + is_correct скрывается, explanation тоже) +- Заглушки `notImplemented` (501) для остальных эндпоинтов + +**Portal-backend:** +- Migration `049_learning_permissions.up.sql` — 3 пермы + грантование admin'у +- `/api/learning/*` — прокси под `service.learning.access` +- `/public/learning/*` — прокси без auth для кандидатов +- `Services.LearningURL` + env-переменная + +**Frontend:** +- Route `/learning/*` под guard'ом `service.learning.access` +- Landing-страница с тремя картчоками +- `/learning/tests` — список + создание (полностью рабочий) +- `/learning/courses`, `/learning/admin` — заглушки `LearningStubComponent` +- Sidebar-пункт «Обучение» + sub-навигация + +**k8s:** полный набор манифестов (namespace / configmap / secrets / +postgres / deployment с HPA / service с portal-discovery annotations). + +## Что отложено в следующие итерации + +| Фича | План | +|---|---| +| **Attempts (прохождение)** | Repository + handler: start attempt → fetch questions → submit → auto-grade (single/multi) + manual review для text | +| **Courses + Lessons CRUD** | Repository + handler по тому же паттерну что Tests | +| **Видео-стрим** | MinIO wrapper в `internal/storage/`, handler `/lessons/{id}/video/stream` с Range-поддержкой; копировать stream-pattern из telephony | +| **Access grants** | Repository + handler + helper «может ли user X пройти ресурс Y» (учитывает user/role/department/position иерархию через portal-internal API) | +| **Public tokens** | Repository + handler: создание (HR из portal'а), resolve (email-match) — на портале и стрим в proxy без auth | +| **Email-отправка** | Через portal Kerio integration — handler выдаёт сформированный URL + текст письма HR'у, реальная отправка через portal | +| **Frontend: конструктор теста** | Drag-and-drop редактор вопросов/ответов (паттерн как kanban-column в tasks) | +| **Frontend: плеер видео-урока** | Кастомный плеер по аналогии с call-audio-player (blob URL, controlsList=nodownload) | +| **Frontend: public-страница кандидата** | Отдельный route `/public/learning/test/:token` без layout'а портала | + +## Локальный запуск + +```bash +docker compose up -d postgres minio # или используй существующие +make tidy +DATABASE_URL=postgres://learning:learning@localhost:5432/learning?sslmode=disable \ + INTERNAL_API_KEY=devkey \ + MINIO_ENDPOINT=localhost:9000 \ + MINIO_ACCESS_KEY=minioadmin \ + MINIO_SECRET_KEY=minioadmin \ + make run +``` + +## Deploy + +```bash +kubectl apply -k k8s/ +# Образ собирается в CI или вручную: +docker build -f Dockerfile.server -t localhost:30300/admin/learning-server:latest . +docker push localhost:30300/admin/learning-server:latest +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..37e18d7 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,133 @@ +// learning-server — микросервис обучения: тесты, курсы (с видео), +// гранулярные доступы и public-ссылки для кандидатов с email-валидацией. +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + chimw "github.com/go-chi/chi/v5/middleware" + + commondb "gitea.estateliga.work/admin/portal-common/db" + commonmw "gitea.estateliga.work/admin/portal-common/middleware" + + "learning-service/internal/config" + "learning-service/internal/handler" + "learning-service/internal/migrate" + "learning-service/internal/repository" +) + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + cfg := config.Load() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + pool, err := commondb.ConnectURL(cfg.DatabaseURL) + if err != nil { + slog.Error("connect database", "error", err) + os.Exit(1) + } + defer pool.Close() + + if err := migrate.Run(context.Background(), pool, cfg.MigrationsDir); err != nil { + slog.Error("run migrations", "error", err) + os.Exit(1) + } + + testRepo := repository.NewTestRepository(pool) + + healthH := handler.NewHealthHandler(pool) + testH := handler.NewTestHandler(testRepo) + + r := chi.NewRouter() + r.Use(chimw.RequestID) + r.Use(chimw.RealIP) + r.Use(chimw.Recoverer) + + r.Get("/healthz", healthH.Healthz) + r.Get("/readyz", healthH.Readyz) + + r.Route("/api", func(r chi.Router) { + r.Use(commonmw.InternalAuth(cfg.InternalAPIKey)) + + // Tests CRUD + r.Get("/tests", testH.List) + r.Post("/tests", testH.Create) + r.Get("/tests/{id}", testH.Get) + r.Patch("/tests/{id}", testH.Update) + r.Delete("/tests/{id}", testH.Delete) + + // Questions внутри теста + r.Get("/tests/{id}/questions", testH.ListQuestions) + r.Post("/tests/{id}/questions", testH.CreateQuestion) + r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion) + + // Заглушки на следующие итерации. Возвращают 501 — фронт может + // рендерить «функция в разработке», если случайно дёрнет. + r.HandleFunc("/tests/{id}/attempts", notImplemented) + r.HandleFunc("/attempts/{id}", notImplemented) + r.HandleFunc("/attempts/{id}/submit", notImplemented) + r.HandleFunc("/courses", notImplemented) + r.HandleFunc("/courses/{id}", notImplemented) + r.HandleFunc("/courses/{id}/lessons", notImplemented) + r.HandleFunc("/lessons/{id}", notImplemented) + r.HandleFunc("/lessons/{id}/video", notImplemented) + r.HandleFunc("/lessons/{id}/video/stream", notImplemented) + r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented) + r.HandleFunc("/public-tokens", notImplemented) + r.HandleFunc("/public-tokens/{id}", notImplemented) + }) + + // Public endpoints — без InternalAuth (кандидаты ходят анонимно + // по token'у). Открываем минимум: проверка токена + старт attempt + + // сабмит. Содержательно гейтим через token-валидацию в самом handler'е. + r.Route("/public", func(r chi.Router) { + r.HandleFunc("/learning/resolve/{token}", notImplemented) + r.HandleFunc("/learning/attempts/{id}", notImplemented) + r.HandleFunc("/learning/attempts/{id}/submit", notImplemented) + }) + + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("learning server starting", "port", cfg.ServerPort, "pod", cfg.PodName) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "error", err) + os.Exit(1) + } + }() + + <-ctx.Done() + slog.Info("shutting down server...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + slog.Error("server shutdown error", "error", err) + } + slog.Info("server stopped") +} + +// notImplemented — заглушка для эндпоинтов, которые есть в схеме, но +// ещё не имплементированы. Возвращает 501 + понятное сообщение, чтобы +// фронт мог отрендерить «в разработке» вместо generic 500. +func notImplemented(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":"not implemented yet — следующая итерация"}`)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..68f0a9a --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module learning-service + +go 1.25.7 + +require ( + gitea.estateliga.work/admin/portal-common v0.2.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.1 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) + +// Если ведёшь параллельно правки в portal-common — раскомментируй replace. +// replace gitea.estateliga.work/admin/portal-common => ../portal-common diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3e409e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE= +gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk= +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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8b351a1 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,97 @@ +// Package config — env-based конфиг learning-сервиса. Стиль envStr идентичен +// candidates/tasks/booking — чтобы k8s-манифесты выглядели единообразно. +package config + +import ( + "os" + "strconv" +) + +type Config struct { + ServerPort string + DatabaseURL string + MigrationsDir string + + // InternalAPIKey — общий ключ с порталом для server-to-server auth. + // Portal-proxy ставит X-Internal-Key + X-User-Id; сервис верифицирует + // ключ и доверяет X-User-Id (JWT уже провалидирован на портале). + InternalAPIKey string + + // PortalURL — для in-app уведомлений (назначен курс/тест/пройден тест). + // Если пусто — notify-вызовы тихо no-op'ятся, основной flow работает. + PortalURL string + + // PublicBaseURL — внешний origin, под которым отдаются public-ссылки + // для кандидатов (например, https://portal.estateliga.work). Используется + // в формате URL'ей: /public/learning/test/. + PublicBaseURL string + + // MinIO для видео-уроков. Структура та же что в telephony (records bucket), + // но bucket отдельный — public-link'и читают видео через стрим-прокси. + MinIOEndpoint string + MinIOAccessKey string + MinIOSecretKey string + MinIOBucket string + MinIOUseSSL bool + + // RedisAddr — пустой = eventbus отключён, сервис работает без публикации + // событий (попадание кандидата на тест не уведомит HR в realtime). + RedisAddr string + RedisPassword string + RedisDB int + + PodName string +} + +func Load() *Config { + return &Config{ + ServerPort: envStr("SERVER_PORT", "3001"), + DatabaseURL: envStr("DATABASE_URL", "postgres://learning:learning@localhost:5432/learning?sslmode=disable"), + MigrationsDir: envStr("MIGRATIONS_DIR", "/migrations"), + InternalAPIKey: envStr("INTERNAL_API_KEY", envStr("PORTAL_INTERNAL_API_KEY", "")), + PortalURL: envStr("PORTAL_URL", ""), + PublicBaseURL: envStr("PUBLIC_BASE_URL", ""), + MinIOEndpoint: envStr("MINIO_ENDPOINT", ""), + MinIOAccessKey: envStr("MINIO_ACCESS_KEY", ""), + MinIOSecretKey: envStr("MINIO_SECRET_KEY", ""), + MinIOBucket: envStr("MINIO_BUCKET", "learning-videos"), + MinIOUseSSL: envBool("MINIO_USE_SSL", false), + RedisAddr: envStr("REDIS_ADDR", ""), + RedisPassword: envStr("REDIS_PASSWORD", ""), + RedisDB: envInt("REDIS_DB", 0), + PodName: envStr("POD_NAME", hostname()), + } +} + +func envInt(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func envBool(key string, def bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return def +} + +func envStr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func hostname() string { + h, err := os.Hostname() + if err != nil { + return "unknown" + } + return h +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..4603148 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,34 @@ +package handler + +import ( + "context" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type HealthHandler struct { + pool *pgxpool.Pool +} + +func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { + return &HealthHandler{pool: pool} +} + +// Healthz — liveness. Не дёргает БД; жив если процесс отвечает. +func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// Readyz — readiness. Один Ping к БД с таймаутом — если не отвечает, +// k8s выкидывает pod из service-балансира. +func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := h.pool.Ping(ctx); err != nil { + writeError(w, http.StatusServiceUnavailable, "db not ready") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) +} diff --git a/internal/handler/helpers.go b/internal/handler/helpers.go new file mode 100644 index 0000000..cbbc0dc --- /dev/null +++ b/internal/handler/helpers.go @@ -0,0 +1,63 @@ +package handler + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/google/uuid" + + "learning-service/internal/repository" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]any{"error": msg}) +} + +// writeRepoError — маппит repository.Err* на HTTP-коды. Для остальных +// логируем и отдаём 500 без деталей (наружу не светим внутренние ошибки). +func writeRepoError(w http.ResponseWriter, r *http.Request, err error, action string) { + switch { + case errors.Is(err, repository.ErrNotFound): + writeError(w, http.StatusNotFound, "not found") + case errors.Is(err, repository.ErrForbidden): + writeError(w, http.StatusForbidden, "forbidden") + default: + slog.Error("repo error", "action", action, "error", err, "path", r.URL.Path) + writeError(w, http.StatusInternalServerError, "internal error") + } +} + +func decodeJSON(r *http.Request, v any) error { + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + return dec.Decode(v) +} + +func parseUUID(s string) (uuid.UUID, error) { + if s == "" { + return uuid.Nil, errors.New("empty uuid") + } + return uuid.Parse(s) +} + +// userIDFromHeader — portal-gateway проставляет X-User-Id после валидации JWT. +// Все НЕ-internal handler'ы читают отсюда; пустой = unauthorized. +func userIDFromHeader(r *http.Request) (uuid.UUID, bool) { + v := r.Header.Get("X-User-Id") + if v == "" { + return uuid.Nil, false + } + id, err := uuid.Parse(v) + if err != nil { + return uuid.Nil, false + } + return id, true +} diff --git a/internal/handler/test.go b/internal/handler/test.go new file mode 100644 index 0000000..0699114 --- /dev/null +++ b/internal/handler/test.go @@ -0,0 +1,260 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "learning-service/internal/model" + "learning-service/internal/repository" +) + +type TestHandler struct { + repo *repository.TestRepository +} + +func NewTestHandler(repo *repository.TestRepository) *TestHandler { + return &TestHandler{repo: repo} +} + +// List — GET /tests. Параметры: +// ?mine=true — только мои (owner_user_id = X-User-Id); +// без mine — published тесты (для прохождения). +// +// MVP: ещё не подключён access_grants-фильтр; до этого момента «published» +// = «всем видно». Следующая итерация: handler через AccessRepository +// получит visibleIDs и передаст в repo.List. +func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + mine := r.URL.Query().Get("mine") == "true" + var ownerFilter *uuid.UUID + if mine { + ownerFilter = &uid + } + tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil) + if err != nil { + writeRepoError(w, r, err, "list tests") + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": tests}) +} + +func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) { + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + t, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + writeJSON(w, http.StatusOK, t) +} + +func (h *TestHandler) Create(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + var req model.CreateTestRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if strings.TrimSpace(req.Title) == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + t, err := h.repo.Create(r.Context(), uid, req) + if err != nil { + writeRepoError(w, r, err, "create test") + return + } + writeJSON(w, http.StatusCreated, t) +} + +// Update / Delete: пока без access-проверки (только owner). Когда подключим +// access_grants с can_manage, разрешим co-owner'ам тоже редактировать. +func (h *TestHandler) Update(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get test for update") + return + } + if existing.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only owner can edit") + return + } + var req model.UpdateTestRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + t, err := h.repo.Update(r.Context(), id, req) + if err != nil { + writeRepoError(w, r, err, "update test") + return + } + writeJSON(w, http.StatusOK, t) +} + +func (h *TestHandler) Delete(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + existing, err := h.repo.Get(r.Context(), id) + if err != nil { + writeRepoError(w, r, err, "get test for delete") + return + } + if existing.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only owner can delete") + return + } + if err := h.repo.Delete(r.Context(), id); err != nil { + writeRepoError(w, r, err, "delete test") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ListQuestions — вопросы теста с ответами. Если запросивший не владелец — +// is_correct поле обнуляется в ответе (показывать правильные ответы до +// сабмита нельзя — иначе тест теряет смысл). +func (h *TestHandler) ListQuestions(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + testID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + t, err := h.repo.Get(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + qs, err := h.repo.ListQuestions(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "list questions") + return + } + // Не-владельцу скрываем is_correct, чтобы он не подсмотрел через + // DevTools правильные ответы до сабмита. + if t.OwnerUserID != uid { + for i := range qs { + for j := range qs[i].Answers { + qs[i].Answers[j].IsCorrect = false + } + // Объяснение тоже не светим — оно потенциально содержит + // разбор правильного ответа. + qs[i].Explanation = "" + } + } + writeJSON(w, http.StatusOK, map[string]any{"items": qs}) +} + +func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + testID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + t, err := h.repo.Get(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + if t.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only owner can add questions") + return + } + var req model.CreateQuestionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if strings.TrimSpace(req.Text) == "" { + writeError(w, http.StatusBadRequest, "text is required") + return + } + if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" { + writeError(w, http.StatusBadRequest, "kind must be single|multi|text") + return + } + if req.Points <= 0 { + req.Points = 1 + } + q, err := h.repo.CreateQuestion(r.Context(), testID, req) + if err != nil { + writeRepoError(w, r, err, "create question") + return + } + writeJSON(w, http.StatusCreated, q) +} + +func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) { + uid, ok := userIDFromHeader(r) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + testID, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + qID, err := parseUUID(chi.URLParam(r, "questionId")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid question id") + return + } + t, err := h.repo.Get(r.Context(), testID) + if err != nil { + writeRepoError(w, r, err, "get test") + return + } + if t.OwnerUserID != uid { + writeError(w, http.StatusForbidden, "only owner can delete questions") + return + } + if err := h.repo.DeleteQuestion(r.Context(), qID); err != nil { + writeRepoError(w, r, err, "delete question") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go new file mode 100644 index 0000000..c8af612 --- /dev/null +++ b/internal/migrate/migrate.go @@ -0,0 +1,58 @@ +// Package migrate — runner sql-миграций, идентичный candidates/tasks/booking. +package migrate + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error { + _, err := pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `) + if err != nil { + return fmt.Errorf("create migrations table: %w", err) + } + + files, err := filepath.Glob(filepath.Join(migrationsDir, "*.up.sql")) + if err != nil { + return fmt.Errorf("glob migrations: %w", err) + } + sort.Strings(files) + + for _, f := range files { + version := strings.TrimSuffix(filepath.Base(f), ".up.sql") + var exists bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, + version).Scan(&exists); err != nil { + return fmt.Errorf("check migration %s: %w", version, err) + } + if exists { + continue + } + sql, err := os.ReadFile(f) + if err != nil { + return fmt.Errorf("read migration %s: %w", version, err) + } + if _, err := pool.Exec(ctx, string(sql)); err != nil { + return fmt.Errorf("apply migration %s: %w", version, err) + } + if _, err := pool.Exec(ctx, + `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil { + return fmt.Errorf("record migration %s: %w", version, err) + } + slog.Info("applied migration", "version", version) + } + return nil +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..a3481ed --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,249 @@ +// Package model — DTO/доменные структуры learning-сервиса. Поля -> JSON +// маппятся 1-в-1 на сообщения frontend'а; cross-service id'шники (owner_user_id, +// candidate_id) — opaque UUID/int64, портал валидирует существование сам. +package model + +import ( + "time" + + "github.com/google/uuid" +) + +// ============================================================ +// TESTS +// ============================================================ + +type Test struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + PassingScore *int `json:"passing_score"` + MaxAttempts int `json:"max_attempts"` + TimeLimitSec int `json:"time_limit_sec"` + ShuffleQuestions bool `json:"shuffle_questions"` + ShowCorrectAfter bool `json:"show_correct_after"` + IsPublished bool `json:"is_published"` + OwnerUserID uuid.UUID `json:"owner_user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // QuestionsCount — populate'ится через correlated subquery в List + // (не отдельной таблицей), даёт UI быстрый бейдж «N вопросов». + QuestionsCount int `json:"questions_count,omitempty"` +} + +type CreateTestRequest struct { + Title string `json:"title"` + Description string `json:"description"` + PassingScore *int `json:"passing_score"` + MaxAttempts int `json:"max_attempts"` + TimeLimitSec int `json:"time_limit_sec"` + ShuffleQuestions bool `json:"shuffle_questions"` + ShowCorrectAfter bool `json:"show_correct_after"` +} + +// UpdateTestRequest — все поля nullable, обновляем только заполненные +// (паттерн COALESCE($X, current) или conditional UPDATE). +type UpdateTestRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + PassingScore *int `json:"passing_score"` + MaxAttempts *int `json:"max_attempts"` + TimeLimitSec *int `json:"time_limit_sec"` + ShuffleQuestions *bool `json:"shuffle_questions"` + ShowCorrectAfter *bool `json:"show_correct_after"` + IsPublished *bool `json:"is_published"` +} + +type Question struct { + ID uuid.UUID `json:"id"` + TestID uuid.UUID `json:"test_id"` + Position int `json:"position"` + Kind string `json:"kind"` // single | multi | text + Text string `json:"text"` + Points int `json:"points"` + Explanation string `json:"explanation"` + CreatedAt time.Time `json:"created_at"` + Answers []Answer `json:"answers,omitempty"` +} + +type Answer struct { + ID uuid.UUID `json:"id"` + QuestionID uuid.UUID `json:"question_id"` + Position int `json:"position"` + Text string `json:"text"` + // IsCorrect — фильтруется handler'ом при выдаче респонденту: показываем + // только после сабмита (если show_correct_after) или владельцу теста. + IsCorrect bool `json:"is_correct,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateQuestionRequest struct { + Position int `json:"position"` + Kind string `json:"kind"` + Text string `json:"text"` + Points int `json:"points"` + Explanation string `json:"explanation"` + Answers []CreateAnswerRequest `json:"answers"` +} + +type CreateAnswerRequest struct { + Position int `json:"position"` + Text string `json:"text"` + IsCorrect bool `json:"is_correct"` +} + +// ============================================================ +// ATTEMPTS +// ============================================================ + +type Attempt struct { + ID uuid.UUID `json:"id"` + TestID uuid.UUID `json:"test_id"` + UserID *uuid.UUID `json:"user_id"` + PublicTokenID *uuid.UUID `json:"public_token_id"` + CandidateID *int64 `json:"candidate_id"` + Status string `json:"status"` // in_progress | submitted | graded | expired + Score *int `json:"score"` + MaxScore *int `json:"max_score"` + Passed *bool `json:"passed"` + RespondentName string `json:"respondent_name"` + RespondentEmail string `json:"respondent_email"` + StartedAt time.Time `json:"started_at"` + SubmittedAt *time.Time `json:"submitted_at"` + GradedAt *time.Time `json:"graded_at"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` +} + +// SubmitAttemptRequest — отправка ответов. AttemptAnswers идут одной +// пачкой: для каждого вопроса либо single/multi (по id'шникам), либо text. +type SubmitAttemptRequest struct { + Answers []SubmitAnswer `json:"answers"` +} + +type SubmitAnswer struct { + QuestionID uuid.UUID `json:"question_id"` + AnswerID *uuid.UUID `json:"answer_id,omitempty"` // single + AnswerIDs []uuid.UUID `json:"answer_ids,omitempty"` // multi + Text string `json:"text,omitempty"` // text +} + +// ============================================================ +// COURSES +// ============================================================ + +type Course struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Slug string `json:"slug"` + CoverImageKey string `json:"cover_image_key"` + IsPublished bool `json:"is_published"` + OwnerUserID uuid.UUID `json:"owner_user_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LessonsCount int `json:"lessons_count,omitempty"` +} + +type CreateCourseRequest struct { + Title string `json:"title"` + Description string `json:"description"` + Slug string `json:"slug"` +} + +type UpdateCourseRequest struct { + Title *string `json:"title"` + Description *string `json:"description"` + Slug *string `json:"slug"` + CoverImageKey *string `json:"cover_image_key"` + IsPublished *bool `json:"is_published"` +} + +type Lesson struct { + ID uuid.UUID `json:"id"` + CourseID uuid.UUID `json:"course_id"` + Position int `json:"position"` + Title string `json:"title"` + Markdown string `json:"markdown"` + VideoKey string `json:"video_key"` + VideoDurationSec int `json:"video_duration_sec"` + TestID *uuid.UUID `json:"test_id"` + IsRequired bool `json:"is_required"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateLessonRequest struct { + Position int `json:"position"` + Title string `json:"title"` + Markdown string `json:"markdown"` + TestID *uuid.UUID `json:"test_id"` + IsRequired *bool `json:"is_required"` +} + +type UpdateLessonRequest struct { + Position *int `json:"position"` + Title *string `json:"title"` + Markdown *string `json:"markdown"` + VideoKey *string `json:"video_key"` + VideoDurationSec *int `json:"video_duration_sec"` + TestID *uuid.UUID `json:"test_id"` + IsRequired *bool `json:"is_required"` +} + +// ============================================================ +// ACCESS GRANTS +// ============================================================ + +type AccessGrant struct { + ID uuid.UUID `json:"id"` + ResourceType string `json:"resource_type"` // test | course + ResourceID uuid.UUID `json:"resource_id"` + SubjectType string `json:"subject_type"` // user | role | department | position | public + SubjectID *uuid.UUID `json:"subject_id"` + CanManage bool `json:"can_manage"` + GrantedBy uuid.UUID `json:"granted_by"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateAccessGrantRequest struct { + SubjectType string `json:"subject_type"` + SubjectID *uuid.UUID `json:"subject_id"` + CanManage bool `json:"can_manage"` +} + +// ============================================================ +// PUBLIC TOKENS +// ============================================================ + +type PublicToken struct { + ID uuid.UUID `json:"id"` + Token string `json:"token"` + ResourceType string `json:"resource_type"` + ResourceID uuid.UUID `json:"resource_id"` + IntendedEmail string `json:"intended_email"` + CandidateID *int64 `json:"candidate_id"` + MaxAttempts int `json:"max_attempts"` + UsedAttempts int `json:"used_attempts"` + ExpiresAt *time.Time `json:"expires_at"` + OpenedAt *time.Time `json:"opened_at"` + UsedAt *time.Time `json:"used_at"` + RevokedAt *time.Time `json:"revoked_at"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt time.Time `json:"created_at"` +} + +type CreatePublicTokenRequest struct { + ResourceType string `json:"resource_type"` + ResourceID uuid.UUID `json:"resource_id"` + IntendedEmail string `json:"intended_email"` + CandidateID *int64 `json:"candidate_id"` + MaxAttempts int `json:"max_attempts"` + ExpiresAt *time.Time `json:"expires_at"` +} + +// PublicTokenResolveRequest — что приходит при «открытии» public-ссылки: +// email из формы, его сверяем с intended_email. UA/IP берём из request'а. +type PublicTokenResolveRequest struct { + Email string `json:"email"` +} diff --git a/internal/repository/errors.go b/internal/repository/errors.go new file mode 100644 index 0000000..68931de --- /dev/null +++ b/internal/repository/errors.go @@ -0,0 +1,11 @@ +package repository + +import "errors" + +// ErrNotFound — ресурс не найден. Handler'ы маппят на 404. +var ErrNotFound = errors.New("not found") + +// ErrForbidden — у вызывающего нет прав на ресурс. Handler'ы маппят на 403. +// Используем repo-уровень для случаев когда проверка прав встроена в SQL +// (например, owner_user_id фильтрация). +var ErrForbidden = errors.New("forbidden") diff --git a/internal/repository/test.go b/internal/repository/test.go new file mode 100644 index 0000000..766d2c8 --- /dev/null +++ b/internal/repository/test.go @@ -0,0 +1,282 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "learning-service/internal/model" +) + +type TestRepository struct { + pool *pgxpool.Pool +} + +func NewTestRepository(pool *pgxpool.Pool) *TestRepository { + return &TestRepository{pool: pool} +} + +// testCols включает correlated subquery для счётчика вопросов — UI получает +// бейджи без отдельного round-trip'а на каждый тест в списке. +const testCols = ` + t.id, t.title, t.description, t.passing_score, t.max_attempts, + t.time_limit_sec, t.shuffle_questions, t.show_correct_after, + t.is_published, t.owner_user_id, t.created_at, t.updated_at, + (SELECT COUNT(*)::int FROM test_questions q WHERE q.test_id = t.id) AS questions_count +` + +func scanTest(scan func(...any) error) (*model.Test, error) { + var t model.Test + if err := scan( + &t.ID, &t.Title, &t.Description, &t.PassingScore, &t.MaxAttempts, + &t.TimeLimitSec, &t.ShuffleQuestions, &t.ShowCorrectAfter, + &t.IsPublished, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt, + &t.QuestionsCount, + ); err != nil { + return nil, err + } + return &t, nil +} + +// List — листинг тестов. Фильтр onlyPublished исключает черновики (для не-владельца). +// ownerFilter (не nil) — показать только тесты конкретного юзера. +// +// Для гранулярного access-check'а (через access_grants) предусмотрено +// поле visibleIDs: если не nil, добавляется фильтр id = ANY($X). Это +// позволяет handler'у дёрнуть access-репо отдельно и передать id'шники +// сюда — repo не зависит от access-репо (плоский граф). +func (r *TestRepository) List(ctx context.Context, ownerFilter *uuid.UUID, onlyPublished bool, visibleIDs []uuid.UUID) ([]model.Test, error) { + conds := []string{} + args := []any{} + push := func(cond string, val any) { + args = append(args, val) + conds = append(conds, strings.Replace(cond, "?", fmt.Sprintf("$%d", len(args)), 1)) + } + if ownerFilter != nil { + push("t.owner_user_id = ?", *ownerFilter) + } + if onlyPublished { + conds = append(conds, "t.is_published = TRUE") + } + if visibleIDs != nil { + // nil = без фильтра, пустой массив = «ничего не видно». + if len(visibleIDs) == 0 { + return []model.Test{}, nil + } + push("t.id = ANY(?)", visibleIDs) + } + where := "" + if len(conds) > 0 { + where = "WHERE " + strings.Join(conds, " AND ") + } + q := fmt.Sprintf(`SELECT %s FROM tests t %s ORDER BY t.updated_at DESC`, testCols, where) + rows, err := r.pool.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Test{} + for rows.Next() { + t, err := scanTest(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (r *TestRepository) Get(ctx context.Context, id uuid.UUID) (*model.Test, error) { + q := fmt.Sprintf(`SELECT %s FROM tests t WHERE t.id = $1`, testCols) + t, err := scanTest(r.pool.QueryRow(ctx, q, id).Scan) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return t, nil +} + +func (r *TestRepository) Create(ctx context.Context, ownerID uuid.UUID, req model.CreateTestRequest) (*model.Test, error) { + var id uuid.UUID + err := r.pool.QueryRow(ctx, ` + INSERT INTO tests ( + title, description, passing_score, max_attempts, time_limit_sec, + shuffle_questions, show_correct_after, owner_user_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + req.Title, req.Description, req.PassingScore, req.MaxAttempts, + req.TimeLimitSec, req.ShuffleQuestions, req.ShowCorrectAfter, ownerID, + ).Scan(&id) + if err != nil { + return nil, err + } + return r.Get(ctx, id) +} + +func (r *TestRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateTestRequest) (*model.Test, error) { + sets := []string{} + args := []any{} + add := func(col string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args))) + } + if req.Title != nil { + add("title", *req.Title) + } + if req.Description != nil { + add("description", *req.Description) + } + if req.PassingScore != nil { + add("passing_score", *req.PassingScore) + } + if req.MaxAttempts != nil { + add("max_attempts", *req.MaxAttempts) + } + if req.TimeLimitSec != nil { + add("time_limit_sec", *req.TimeLimitSec) + } + if req.ShuffleQuestions != nil { + add("shuffle_questions", *req.ShuffleQuestions) + } + if req.ShowCorrectAfter != nil { + add("show_correct_after", *req.ShowCorrectAfter) + } + if req.IsPublished != nil { + add("is_published", *req.IsPublished) + } + if len(sets) == 0 { + return r.Get(ctx, id) + } + sets = append(sets, "updated_at = NOW()") + args = append(args, id) + q := fmt.Sprintf(`UPDATE tests SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args)) + tag, err := r.pool.Exec(ctx, q, args...) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrNotFound + } + return r.Get(ctx, id) +} + +func (r *TestRepository) Delete(ctx context.Context, id uuid.UUID) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM tests WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ListQuestions — все вопросы теста, плюс ответы (через отдельный запрос +// и in-memory join по question_id). Дёшево — обычно у теста 5-30 вопросов +// по 2-4 ответа = единицы сотен строк. +func (r *TestRepository) ListQuestions(ctx context.Context, testID uuid.UUID) ([]model.Question, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id, test_id, position, kind, text, points, explanation, created_at + FROM test_questions + WHERE test_id = $1 + ORDER BY position, created_at`, testID) + if err != nil { + return nil, err + } + defer rows.Close() + qs := []model.Question{} + qIDs := []uuid.UUID{} + for rows.Next() { + var q model.Question + if err := rows.Scan(&q.ID, &q.TestID, &q.Position, &q.Kind, &q.Text, &q.Points, &q.Explanation, &q.CreatedAt); err != nil { + return nil, err + } + qs = append(qs, q) + qIDs = append(qIDs, q.ID) + } + if len(qs) == 0 { + return qs, nil + } + // Один батч-запрос на все ответы. + aRows, err := r.pool.Query(ctx, ` + SELECT id, question_id, position, text, is_correct, created_at + FROM test_answers + WHERE question_id = ANY($1) + ORDER BY position, created_at`, qIDs) + if err != nil { + return nil, err + } + defer aRows.Close() + byQ := map[uuid.UUID][]model.Answer{} + for aRows.Next() { + var a model.Answer + if err := aRows.Scan(&a.ID, &a.QuestionID, &a.Position, &a.Text, &a.IsCorrect, &a.CreatedAt); err != nil { + return nil, err + } + byQ[a.QuestionID] = append(byQ[a.QuestionID], a) + } + for i := range qs { + qs[i].Answers = byQ[qs[i].ID] + } + return qs, nil +} + +// CreateQuestion — транзакционно создаёт вопрос + ответы (отдельный INSERT'ом +// на каждый, без bulk — у теста типично 2-6 ответов на вопрос). +func (r *TestRepository) CreateQuestion(ctx context.Context, testID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback(ctx) }() + + var qID uuid.UUID + err = tx.QueryRow(ctx, ` + INSERT INTO test_questions (test_id, position, kind, text, points, explanation) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, + testID, req.Position, req.Kind, req.Text, req.Points, req.Explanation, + ).Scan(&qID) + if err != nil { + return nil, err + } + for _, a := range req.Answers { + if _, err := tx.Exec(ctx, ` + INSERT INTO test_answers (question_id, position, text, is_correct) + VALUES ($1, $2, $3, $4)`, + qID, a.Position, a.Text, a.IsCorrect); err != nil { + return nil, err + } + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + // Перечитываем целиком, чтобы вернуть и вопрос, и ответы. + all, err := r.ListQuestions(ctx, testID) + if err != nil { + return nil, err + } + for _, q := range all { + if q.ID == qID { + return &q, nil + } + } + return nil, ErrNotFound +} + +func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUID) error { + tag, err := r.pool.Exec(ctx, `DELETE FROM test_questions WHERE id = $1`, questionID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..5c1bc76 --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: learning-config + namespace: learning +data: + DATABASE_URL: "postgres://learning:learning@postgres.learning.svc.cluster.local:5432/learning?sslmode=disable" + SERVER_PORT: "3001" + # PORTAL_URL — для in-app уведомлений HR'у когда кандидат проходит тест, + # сотруднику когда ему назначили курс. Пустой = notify-вызовы no-op'ятся. + PORTAL_URL: "http://portal-server.portal.svc.cluster.local" + # PUBLIC_BASE_URL — внешний origin для public-ссылок кандидатам. + # Сервис не делает редиректов сам — он только подставляет origin + # в URL'и при создании public_token (для копи-пасты в email-шаблон). + PUBLIC_BASE_URL: "https://portal.estateliga.work" + # MinIO для видео-уроков. Bucket'ы создаются вручную (как для telephony- + # records). UseSSL=false внутри кластера. + MINIO_ENDPOINT: "minio.minio.svc.cluster.local:9000" + MINIO_BUCKET: "learning-videos" + MINIO_USE_SSL: "false" + REDIS_ADDR: "redis.redis.svc.cluster.local:6379" + REDIS_DB: "0" diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..9f1258f --- /dev/null +++ b/k8s/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: learning + +resources: + - namespace.yaml + - configmap.yaml + - secrets.yaml + - postgres.yaml + - server-deployment.yaml + - server-service.yaml diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..127d6cb --- /dev/null +++ b/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: learning diff --git a/k8s/postgres.yaml b/k8s/postgres.yaml new file mode 100644 index 0000000..bb97d50 --- /dev/null +++ b/k8s/postgres.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: Service +metadata: + name: postgres + namespace: learning +spec: + selector: + app: postgres + ports: + - port: 5432 + targetPort: 5432 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + namespace: learning +spec: + serviceName: postgres + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:16-alpine + ports: + - containerPort: 5432 + envFrom: + - secretRef: + name: postgres-secret + volumeMounts: + - name: pgdata + mountPath: /var/lib/postgresql/data + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + exec: + command: ["pg_isready", "-U", "learning", "-d", "learning"] + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + exec: + command: ["pg_isready", "-U", "learning", "-d", "learning"] + initialDelaySeconds: 5 + periodSeconds: 5 + volumeClaimTemplates: + - metadata: + name: pgdata + spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..8d2c73e --- /dev/null +++ b/k8s/secrets.yaml @@ -0,0 +1,26 @@ +# SECRETS — в открытом виде намеренно, репо приватное в gitea.estateliga.work. +# INTERNAL_API_KEY — общий ключ с порталом (X-Internal-Key, constant-time +# сравнение). MINIO_ACCESS_KEY/SECRET_KEY — учётка с правом на bucket +# learning-videos (создать через MinIO console, scope = read/write). +apiVersion: v1 +kind: Secret +metadata: + name: learning-secrets + namespace: learning +type: Opaque +stringData: + PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734" + INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734" + MINIO_ACCESS_KEY: "learning-svc" + MINIO_SECRET_KEY: "REPLACE_AFTER_FIRST_DEPLOY" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-secret + namespace: learning +type: Opaque +stringData: + POSTGRES_USER: learning + POSTGRES_PASSWORD: learning + POSTGRES_DB: learning diff --git a/k8s/server-deployment.yaml b/k8s/server-deployment.yaml new file mode 100644 index 0000000..6eb0c21 --- /dev/null +++ b/k8s/server-deployment.yaml @@ -0,0 +1,87 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: learning-server + namespace: learning +spec: + replicas: 2 + selector: + matchLabels: + app: learning-server + template: + metadata: + labels: + app: learning-server + spec: + terminationGracePeriodSeconds: 15 + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: learning-server + image: localhost:30300/admin/learning-server:latest + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + ports: + - containerPort: 3001 + envFrom: + - configMapRef: + name: learning-config + - secretRef: + name: learning-secrets + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + startupProbe: + httpGet: + path: /healthz + port: 3001 + periodSeconds: 5 + failureThreshold: 30 + livenessProbe: + httpGet: + path: /healthz + port: 3001 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 3001 + periodSeconds: 5 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + # Видео-стрим может локально жевать память, потому чуть выше. + cpu: 300m + memory: 384Mi +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: learning-server + namespace: learning +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: learning-server + minReplicas: 2 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 diff --git a/k8s/server-service.yaml b/k8s/server-service.yaml new file mode 100644 index 0000000..5ce135f --- /dev/null +++ b/k8s/server-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: learning-server + namespace: learning + annotations: + portal.estateliga.work/enabled: "true" + portal.estateliga.work/name: "Обучение" + portal.estateliga.work/description: "Тесты, курсы, видео-уроки, аттестация" + portal.estateliga.work/icon: "book" + portal.estateliga.work/path: "/api/learning" + portal.estateliga.work/code: "learning" +spec: + selector: + app: learning-server + ports: + - port: 80 + targetPort: 3001 diff --git a/migrations/001_init.down.sql b/migrations/001_init.down.sql new file mode 100644 index 0000000..d2cb00c --- /dev/null +++ b/migrations/001_init.down.sql @@ -0,0 +1,12 @@ +BEGIN; +DROP TABLE IF EXISTS public_tokens; +DROP TABLE IF EXISTS access_grants; +DROP TABLE IF EXISTS lesson_progress; +DROP TABLE IF EXISTS lessons; +DROP TABLE IF EXISTS courses; +DROP TABLE IF EXISTS test_attempt_answers; +DROP TABLE IF EXISTS test_attempts; +DROP TABLE IF EXISTS test_answers; +DROP TABLE IF EXISTS test_questions; +DROP TABLE IF EXISTS tests; +COMMIT; diff --git a/migrations/001_init.up.sql b/migrations/001_init.up.sql new file mode 100644 index 0000000..cc39382 --- /dev/null +++ b/migrations/001_init.up.sql @@ -0,0 +1,290 @@ +-- learning-service: первая миграция. Создаёт схему MVP: +-- тесты + вопросы + ответы + попытки прохождения +-- курсы + уроки (видео + markdown + опциональный тест в конце) +-- access_grants — гранулярные доступы (user / role / department / position) +-- public_tokens — одноразовые ссылки для кандидатов с email-проверкой +-- +-- Cross-service ссылки (subject_id, candidate_id, owner_user_id) хранятся +-- как UUID без FK — portal/candidates живут в других БД. Целостность +-- гарантируется бизнес-логикой (валидация при создании). +-- +-- 1 файл вместо разнесения по фичам — на старте проще читать как единое +-- целое; начнётся эволюция — разнесём. + +BEGIN; + +-- ============================================================ +-- TESTS +-- ============================================================ +-- Тест — самостоятельная единица для прохождения. Может быть прикреплён +-- к уроку курса (lessons.test_id) или существовать отдельно (для public- +-- ссылок кандидатам, ad-hoc проверок и т.п.). +CREATE TABLE tests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + -- passing_score: процент правильных от max, 0..100. 70 = по умолчанию + -- классическая «3+». Если null — авто-зачёт не рассчитываем, + -- HR смотрит результат вручную. + passing_score INT NULL CHECK (passing_score IS NULL OR (passing_score >= 0 AND passing_score <= 100)), + -- max_attempts: 0 = без лимита, N > 0 — попытки ограничены. + max_attempts INT NOT NULL DEFAULT 0 CHECK (max_attempts >= 0), + -- time_limit_sec: 0 = без таймера; иначе UI ставит обратный отсчёт. + time_limit_sec INT NOT NULL DEFAULT 0 CHECK (time_limit_sec >= 0), + -- shuffle_questions: если true, UI каждой попытке отдаёт вопросы в + -- случайном порядке. Серверный select по test_questions.position + -- остаётся; шафлинг на клиенте. + shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE, + -- show_correct_after: показывать правильные ответы по завершении + -- (учебные тесты — да; аттестационные — обычно нет). + show_correct_after BOOLEAN NOT NULL DEFAULT TRUE, + -- is_published — черновики не видны никому кроме owner; published + -- видны всем, кому есть access_grant. + is_published BOOLEAN NOT NULL DEFAULT FALSE, + owner_user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_tests_owner ON tests(owner_user_id); +CREATE INDEX idx_tests_published ON tests(is_published) WHERE is_published = TRUE; + +-- Вопрос теста. kind определяет UI-рендер и логику авто-оценки. +-- single — radio-buttons, ровно один is_correct; +-- multi — checkboxes, может быть >=1 is_correct; +-- text — свободный ввод, оценка вручную HR'ом (auto_grade=false). +CREATE TABLE test_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + -- position: явный порядок (drag&drop в UI выставит). Уникальность + -- НЕ обеспечиваем (двигание через UPDATE батчем — было бы много + -- констрейнт-flip'ов; на дубли клиент не натолкнётся в норме). + position INT NOT NULL DEFAULT 0, + kind TEXT NOT NULL DEFAULT 'single' + CHECK (kind IN ('single','multi','text')), + -- text — текст вопроса; markdown допустим, фронт рендерит. + text TEXT NOT NULL, + -- points: вклад в общий счёт. 1 по умолчанию; для «бонусных» можно 2-3. + points INT NOT NULL DEFAULT 1 CHECK (points > 0), + -- explanation: показывается после ответа (если show_correct_after). + explanation TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_test_questions_test ON test_questions(test_id, position); + +-- Вариант ответа. Для kind=text — не используется (NULL=correct + sample_answer +-- в самом вопросе). Для single/multi — варианты с is_correct-флагом. +CREATE TABLE test_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + text TEXT NOT NULL, + is_correct BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_test_answers_question ON test_answers(question_id, position); + +-- Попытка прохождения. Один из {user_id, public_token_id} обязателен — +-- либо это сотрудник под портальным логином, либо кандидат по public-токену. +-- candidate_id заполняется когда токен был выпущен под конкретного кандидата +-- (см. public_tokens.candidate_id); удобно для join'ов с candidates-сервисом. +CREATE TABLE test_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE, + user_id UUID NULL, + public_token_id UUID NULL, + candidate_id BIGINT NULL, + status TEXT NOT NULL DEFAULT 'in_progress' + CHECK (status IN ('in_progress','submitted','graded','expired')), + -- score: суммарные баллы за правильные ответы; max_score = sum points + -- по test_questions на момент сабмита (фиксируем для read-only результата). + score INT NULL, + max_score INT NULL, + -- passed: рассчитывается при сабмите если у теста passing_score != NULL. + passed BOOLEAN NULL, + -- Денормализованный snapshot ФИО респондента для public-ссылок: + -- кандидат заполняет в форме перед стартом, мы сохраняем, чтобы HR видел + -- кто проходил (без join'а с candidates). + respondent_name TEXT NOT NULL DEFAULT '', + respondent_email TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + submitted_at TIMESTAMPTZ NULL, + graded_at TIMESTAMPTZ NULL, + -- Метаданные клиента — для аудита подозрительных попыток (один токен, + -- разные IP'шники → возможно ссылку перешарили). + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + -- Гарантируем что заполнен хотя бы один из user_id / public_token_id. + CONSTRAINT test_attempts_subject_required CHECK ( + user_id IS NOT NULL OR public_token_id IS NOT NULL + ) +); +CREATE INDEX idx_test_attempts_test ON test_attempts(test_id, started_at DESC); +CREATE INDEX idx_test_attempts_user ON test_attempts(user_id, started_at DESC) + WHERE user_id IS NOT NULL; +CREATE INDEX idx_test_attempts_candidate ON test_attempts(candidate_id, started_at DESC) + WHERE candidate_id IS NOT NULL; +CREATE INDEX idx_test_attempts_token ON test_attempts(public_token_id, started_at DESC) + WHERE public_token_id IS NOT NULL; + +-- Ответ пользователя на конкретный вопрос в попытке. Хранится JSONB, +-- т.к. формат зависит от kind: +-- single → {"answer_id": "uuid"} +-- multi → {"answer_ids": ["uuid", "uuid"]} +-- text → {"text": "...свободный ответ..."} +-- При сабмите attempts-handler сверяется с test_answers.is_correct и +-- считает score; для text — оставляет null до ручной оценки HR'ом. +CREATE TABLE test_attempt_answers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + attempt_id UUID NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE, + payload JSONB NOT NULL, + -- correct: NULL для text (ждёт ручной оценки), true/false для auto-graded. + correct BOOLEAN NULL, + -- score: 0..question.points. Заполняется при сабмите (auto) или вручную HR'ом. + score INT NULL CHECK (score IS NULL OR score >= 0), + answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (attempt_id, question_id) +); + +-- ============================================================ +-- COURSES +-- ============================================================ +-- Курс = упорядоченный список уроков. Уроки могут содержать видео + текст +-- и ОПЦИОНАЛЬНО прикреплённый тест (test_id NULL — урок без проверки). +CREATE TABLE courses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + -- slug: короткий человекочитаемый идентификатор для URL'ей + -- (/learning/c/onboarding-2025). Уникален среди опубликованных. + slug TEXT NOT NULL, + -- cover_image_key: опциональная картинка-обложка (MinIO key). + cover_image_key TEXT NOT NULL DEFAULT '', + is_published BOOLEAN NOT NULL DEFAULT FALSE, + owner_user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (slug) +); +CREATE INDEX idx_courses_owner ON courses(owner_user_id); +CREATE INDEX idx_courses_published ON courses(is_published) WHERE is_published = TRUE; + +-- Урок. Внутри — markdown-тело (всегда есть, может быть пустым) + видео +-- (опционально, через MinIO key) + тест в конце (опционально, FK на tests). +CREATE TABLE lessons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + position INT NOT NULL DEFAULT 0, + title TEXT NOT NULL, + -- markdown: основной контент. Можно использовать сам по себе (без видео). + markdown TEXT NOT NULL DEFAULT '', + -- video_key: ключ объекта в MinIO bucket (см. config.MinIOBucket). + -- При удалении урока физический файл НЕ удаляется — отдельный сборщик + -- мусора по «висящим» ключам, чтобы случайный delete не уничтожил видео. + video_key TEXT NOT NULL DEFAULT '', + video_duration_sec INT NOT NULL DEFAULT 0, + -- test_id: связь с тестом. На уровне урока — для проверки усвоения. + test_id UUID NULL REFERENCES tests(id) ON DELETE SET NULL, + -- is_required: курс не считается пройденным пока этот урок не закрыт. + -- В MVP «закрыт» = выставлен flag в progress-таблице (см. ниже). + is_required BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_lessons_course ON lessons(course_id, position); + +-- Прогресс по урокам. Один ряд = один пользователь × один урок. +-- viewed_at = просмотрел видео/прочитал markdown; completed_at = плюс +-- сдал прикреплённый тест (если он был). Для уроков без теста completed_at +-- ставится одновременно с viewed_at (handler сам отметит). +CREATE TABLE lesson_progress ( + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + viewed_at TIMESTAMPTZ NULL, + completed_at TIMESTAMPTZ NULL, + PRIMARY KEY (lesson_id, user_id) +); +CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id, completed_at DESC); + +-- ============================================================ +-- ACCESS GRANTS +-- ============================================================ +-- Гранулярный доступ к ресурсу. Один ряд = «кому что разрешено». +-- +-- resource_type: 'test' | 'course' +-- subject_type: 'user' | 'role' | 'department' | 'position' | 'public' +-- - user: subject_id = portal user_id (UUID) +-- - role: subject_id = portal role_id (UUID) — все носители роли получают доступ +-- - department: subject_id = portal department_id (UUID) — все из отдела +-- - position: subject_id = portal position_id (UUID) — все на этой должности +-- - public: subject_id = NULL — anyone с действующим public-токеном +-- +-- can_manage: если true — subject может редактировать ресурс (owner + admins +-- получают это автоматически без grant'а; здесь — для «соавторов»). +CREATE TABLE access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')), + resource_id UUID NOT NULL, + subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','department','position','public')), + subject_id UUID NULL, + can_manage BOOLEAN NOT NULL DEFAULT FALSE, + granted_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- public: subject_id обязательно NULL; для остальных типов — обязательно заполнено. + CONSTRAINT access_grants_subject_id_match CHECK ( + (subject_type = 'public' AND subject_id IS NULL) OR + (subject_type <> 'public' AND subject_id IS NOT NULL) + ), + -- Уникальность: не можем выдать два одинаковых грантa. + UNIQUE (resource_type, resource_id, subject_type, subject_id) +); +CREATE INDEX idx_access_resource ON access_grants(resource_type, resource_id); +CREATE INDEX idx_access_subject ON access_grants(subject_type, subject_id) + WHERE subject_id IS NOT NULL; + +-- ============================================================ +-- PUBLIC TOKENS +-- ============================================================ +-- Одноразовая ссылка для кандидата. HR создаёт токен на конкретный тест +-- или курс, привязывает к email + опционально candidate_id, отправляет +-- ссылку /public/learning//. +-- +-- При открытии: +-- 1. Фронт спрашивает email +-- 2. Бэк сверяет с intended_email (case-insensitive). Не совпало → 403. +-- 3. Создаём test_attempt с public_token_id, привязываем opened_at. +-- 4. Юзер проходит, отправляет — used_at заполняется. +-- +-- max_attempts > 1 разрешает несколько прохождений по одной ссылке (например, +-- для tutorial-теста). По умолчанию 1. +CREATE TABLE public_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- token: длинная случайная строка; ходит в URL. Делаем поле UNIQUE, + -- чтобы повторно открыть ссылку и проверить, что она ещё действует. + token TEXT NOT NULL UNIQUE, + resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')), + resource_id UUID NOT NULL, + intended_email TEXT NOT NULL, + -- candidate_id: если HR создавал ссылку из карточки candidate'а — + -- сохраняем для join'а результатов с candidates-сервисом. + candidate_id BIGINT NULL, + max_attempts INT NOT NULL DEFAULT 1 CHECK (max_attempts > 0), + used_attempts INT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ NULL, + -- opened_at: первый раз когда токен был «активирован» (открыт + + -- прошёл email-проверку). Для аудита. + opened_at TIMESTAMPTZ NULL, + -- used_at: первый submitted attempt. После этого, если max_attempts=1, + -- ссылка не открывается заново. + used_at TIMESTAMPTZ NULL, + -- revoked_at: HR может вручную отозвать ссылку до использования. + revoked_at TIMESTAMPTZ NULL, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_public_tokens_resource ON public_tokens(resource_type, resource_id); +CREATE INDEX idx_public_tokens_candidate ON public_tokens(candidate_id) + WHERE candidate_id IS NOT NULL; +CREATE INDEX idx_public_tokens_email ON public_tokens(LOWER(intended_email)); + +COMMIT;