AccessGrantRepository:
- CRUD (List/Create/Get/Delete) с UPSERT на (resource, subject) для
идемпотентности повторных grant'ов.
- ResolveVisibleResourceIDs(viewer ViewerContext) — для данного юзера
возвращает DISTINCT set resource_id'шников, выданных через любой из
subject_type: 'public' OR 'user'==viewer OR 'role'∈viewer.RoleIDs OR
'department'==viewer.DepartmentID OR 'position'==viewer.PositionID.
ViewerContext собирается из X-User-Roles/Department-Id/Position-Id
headers'ов (portal-gateway прокидывает после JWT-валидации).
AccessGrantHandler:
- GET /access/{resourceType}/{resourceId} — list (owner-only).
- POST /access/{resourceType}/{resourceId} — выдать (UPSERT).
- DELETE /access/{resourceType}/{resourceId}/grants/{grantId} —
отозвать. resourceType/Id дублируются в URL'е для cross-check'а
чтобы owner ресурса A не мог удалить grant ресурса B по grantId.
Интеграция в List'ах:
- TestHandler.List: ?mine=true работает как было; без mine видны
published + дозалив unpublished, выданных через access_grants.
- CourseHandler.List: то же поведение зеркально.
Семантика union'а: «published all + grant-only». Это backward-compat
(старые published продолжают быть видны всем), при этом HR может
явно выдать draft-ресурс конкретному юзеру/роли без публикации.
helpers.go: viewerContextFromHeaders — парсит X-User-Roles (CSV),
X-User-Department-Id, X-User-Position-Id; невалидные/пустые → default.
Wire-up: accessRepo внедрён в Test/Course handler'ы; accessH
зарегистрирован вместо предыдущей 501-заглушки.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
414 lines
13 KiB
Go
414 lines
13 KiB
Go
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
|
||
accessRepo *repository.AccessGrantRepository
|
||
}
|
||
|
||
func NewTestHandler(repo *repository.TestRepository, accessRepo *repository.AccessGrantRepository) *TestHandler {
|
||
return &TestHandler{repo: repo, accessRepo: accessRepo}
|
||
}
|
||
|
||
// List — GET /tests. Параметры:
|
||
// ?mine=true — только мои (owner_user_id = X-User-Id);
|
||
// без mine — published тесты, ВИДИМЫЕ юзеру через access_grants.
|
||
//
|
||
// Видимость:
|
||
// - owner всегда видит свои (мимо access_grants);
|
||
// - не-owner видит published + (resolvedVisibleIDs ИЛИ public-grant).
|
||
// MVP: published без явных grants считается «видимым всем» — этот режим
|
||
// сохранён как fallback, чтобы не сломать существующие тесты. Когда фронт
|
||
// начнёт массово выдавать гранты на каждый тест, перейдём на строгий
|
||
// режим (если есть хотя бы один не-public grant, видны только grant'ы).
|
||
//
|
||
// На now: visibleIDs объединяет (own published) ∪ (через access_grants).
|
||
// Если у юзера 0 grant'ов И тест опубликован — он его всё равно видит
|
||
// (publicness == default).
|
||
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"
|
||
if mine {
|
||
tests, err := h.repo.List(r.Context(), &uid, false, nil)
|
||
if err != nil {
|
||
writeRepoError(w, r, err, "list tests")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
|
||
return
|
||
}
|
||
// Не-owner: published + access_grants. Передаём visibleIDs=nil (без
|
||
// фильтра по id) если access_grants пуст — тогда видны все published.
|
||
// Если есть хоть один grant — берём union (id IN grants) OR
|
||
// (is_published AND owner = me); но репо API сейчас принимает один
|
||
// visibleIDs-набор. Расширим в next iteration; в MVP даём оба пути:
|
||
// просто все published, плюс отдельно — все из grant'ов даже если
|
||
// они не published.
|
||
viewer := viewerContextFromHeaders(r, uid)
|
||
grantIDs, err := h.accessRepo.ResolveVisibleResourceIDs(r.Context(), "test", viewer)
|
||
if err != nil {
|
||
writeRepoError(w, r, err, "resolve access")
|
||
return
|
||
}
|
||
tests, err := h.repo.List(r.Context(), nil, true, nil)
|
||
if err != nil {
|
||
writeRepoError(w, r, err, "list tests")
|
||
return
|
||
}
|
||
// Дозаливаем тесты которые выданы через grants но не опубликованы.
|
||
if len(grantIDs) > 0 {
|
||
extra, err := h.repo.List(r.Context(), nil, false, grantIDs)
|
||
if err != nil {
|
||
writeRepoError(w, r, err, "list granted tests")
|
||
return
|
||
}
|
||
seen := map[string]struct{}{}
|
||
for _, t := range tests {
|
||
seen[t.ID.String()] = struct{}{}
|
||
}
|
||
for _, t := range extra {
|
||
if _, ok := seen[t.ID.String()]; ok {
|
||
continue
|
||
}
|
||
// Black-box: уже опубликованные через published-ветку не дублируются;
|
||
// добавляем только новые (выданные через grant, но НЕ published).
|
||
tests = append(tests, t)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
// UpdateQuestion — PUT /tests/{id}/questions/{questionId}. Full-replace
|
||
// семантика (см. repository.UpdateQuestion): метаданные обновляются,
|
||
// ответы пересоздаются. question_id стабилен.
|
||
func (h *TestHandler) UpdateQuestion(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 test 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 edit 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.UpdateQuestion(r.Context(), qID, req)
|
||
if err != nil {
|
||
writeRepoError(w, r, err, "update question")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, q)
|
||
}
|
||
|
||
// ReorderQuestions — POST /tests/{id}/questions/reorder. Body:
|
||
// {"items": [{"id": "uuid", "position": 0}, ...]}
|
||
func (h *TestHandler) ReorderQuestions(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 test 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 reorder")
|
||
return
|
||
}
|
||
var req struct {
|
||
Items []struct {
|
||
ID string `json:"id"`
|
||
Position int `json:"position"`
|
||
} `json:"items"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid body")
|
||
return
|
||
}
|
||
items := make([]struct {
|
||
ID uuid.UUID
|
||
Position int
|
||
}, 0, len(req.Items))
|
||
for _, it := range req.Items {
|
||
id, err := parseUUID(it.ID)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid item id")
|
||
return
|
||
}
|
||
items = append(items, struct {
|
||
ID uuid.UUID
|
||
Position int
|
||
}{ID: id, Position: it.Position})
|
||
}
|
||
if err := h.repo.ReorderQuestions(r.Context(), testID, items); err != nil {
|
||
writeRepoError(w, r, err, "reorder questions")
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
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)
|
||
}
|