axe/internal/handlers/users.go
2026-10-18 22:07:25 +03:00

518 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"errors"
"log/slog"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"building-mgmt/internal/auth"
"building-mgmt/internal/mailer"
"building-mgmt/internal/models"
"building-mgmt/internal/repository"
)
// UserHandler — админские эндпоинты: создание пользователей, выборка
// по роли, включение/выключение аккаунтов, подтверждение регистрации,
// удаление. Клиенту отсюда доступны только мастера: создание, список
// своих (без чужих организаций), подтверждение созданного им мастера
// и выключение мастера из своих проектов.
type UserHandler struct {
repo *repository.ProfileRepo
rtRepo *repository.RefreshTokenRepo
mailer mailer.Mailer
}
// NewUserHandler собирает хендлер: refreshRepo нужен, чтобы деактивация
// закрывала сессии пользователя немедленно, mailer — чтобы подтверждение
// слало письмо «аккаунт подтверждён» (fail-open: без SMTP работает
// как заглушка).
func NewUserHandler(repo *repository.ProfileRepo, rtRepo *repository.RefreshTokenRepo, ml mailer.Mailer) *UserHandler {
return &UserHandler{repo: repo, rtRepo: rtRepo, mailer: ml}
}
type createUserReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
Name string `json:"name" binding:"required"`
Phone string `json:"phone"`
Role string `json:"role" binding:"required,oneof=worker admin client"`
}
// Create — POST /users. Админ создаёт мастеров, админов и клиентов,
// клиент — только мастера. Правило подтверждения — в SQL CreateBy:
// созданное админом подтверждается сразу («создатель и есть
// подтверждающий»), созданное клиентом ждёт его подтверждения
// (POST /users/:id/confirm).
//
// @Summary Создать пользователя
// @Description Админ создаёт мастеров, админов и клиентов; клиент — только мастеров (для себя, далее мастер добавляется в проект через POST /projects/{id}/workers). Роли admin и client клиентом недоступны. Пароль — по политике (10..72 символа, буквы и цифры). Подтверждение: аккаунт, созданный админом, подтверждён сразу; созданный клиентом — ждёт подтверждения этим же клиентом, после чего мастеру уходит письмо.
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Param body body createUserReq true "Данные пользователя"
// @Success 201 {object} models.Profile
// @Failure 400 {object} handlers.ErrorResponse "некорректное тело запроса или слабый пароль"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "клиент пытается создать админа или клиента"
// @Failure 409 {object} handlers.ErrorResponse "email уже занят"
// @Router /users [post]
func (h *UserHandler) Create(c *gin.Context) {
var req createUserReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
// Из клиента ни админа, ни второго клиента не завести —
// это и есть граница полномочий: клиентов создаёт только админ.
if c.GetString("role") == "client" && req.Role != "worker" {
forbidden(c)
return
}
// Политика пароля — после проверки полномочий: сначала «вам это
// нельзя», потом «формат запроса».
if err := auth.ValidatePassword(req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
return
}
profile := &models.Profile{
ID: uuid.NewString(),
Email: req.Email,
PasswordHash: hash,
Name: req.Name,
Phone: req.Phone,
Role: req.Role,
IsActive: true,
}
if err := h.repo.CreateBy(c.Request.Context(), profile, c.GetString("user_id")); err != nil {
if errors.Is(err, repository.ErrConflict) {
c.JSON(http.StatusConflict, gin.H{"error": "email already exists"})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusCreated, profile)
}
// Confirm — POST /users/:id/confirm. Подтвердить аккаунт.
// Право — целиком в SQL репозитория: админ подтверждает любой
// неподтверждённый аккаунт, клиент — только созданного им мастера;
// чужой и несуществующий неразличимы (404), повтор — 409.
//
// @Summary Подтвердить аккаунт
// @Description Подтверждает неподтверждённый аккаунт и шлёт пользователю письмо «аккаунт подтверждён» со ссылкой на вход. Право — в SQL: админ подтверждает любой аккаунт, клиент — только созданного им мастера (иной и несуществующий не видны — 404). Повторное подтверждение — 409. Письмо отправляется после подтверждения; недоступный SMTP не откатывает подтверждение (письмо просто не уйдёт).
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "ID пользователя (uuid)"
// @Success 200 {object} handlers.OKResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не созданный им мастер)"
// @Failure 409 {object} handlers.ErrorResponse "аккаунт уже подтверждён"
// @Router /users/{id}/confirm [post]
func (h *UserHandler) Confirm(c *gin.Context) {
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
ctx := c.Request.Context()
if err := h.repo.ConfirmUser(ctx, id, c.GetString("user_id")); err != nil {
switch {
case errors.Is(err, repository.ErrAlreadyConfirmed):
c.JSON(http.StatusConflict, gin.H{"error": "already confirmed"})
case errors.Is(err, repository.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
default:
respondError(c, err)
}
return
}
// Письмо — после успешного подтверждения, fail-open: недоступный
// SMTP (или несработавший re-read) не должен откатывать ответ —
// подтверждение уже состоялось, в лог уходит warn.
profile, err := h.repo.GetByID(ctx, id)
if err != nil {
slog.Warn("confirmation email skipped: profile reread failed", "err", err, "user_id", id)
} else {
// ConfirmUser уже выставил confirmed_at; защищаемся от nil
// (теоретический рассинхрон репозитория) нулевым временем —
// mailer тогда просто опустит строку с датой.
var confirmedAt time.Time
if profile.ConfirmedAt != nil {
confirmedAt = *profile.ConfirmedAt
}
if err := h.mailer.SendAccountConfirmed(ctx, mailer.AccountConfirmed{
To: profile.Email,
Name: profile.Name,
Role: profile.Role,
ConfirmedAt: confirmedAt,
}); err != nil {
slog.Warn("confirmation email failed", "err", err, "user_id", id)
}
}
c.JSON(http.StatusOK, OKResponse{OK: true})
}
// List — GET /users?role=worker. Админ — все пользователи по роли,
// клиент — только мастера, состоящие в его проектах. Параметр q —
// поиск по имени/email (так клиент находит существующего мастера,
// чтобы пригласить его в проект). include_inactive=1 — админский
// список показывает и отключённых (кнопка «Включить» после отключения
// иначе оказалась бы тупиком); клиент этот флаг не получает.
//
// @Summary Список пользователей по роли
// @Description Админ видит любую роль и всех; клиенту доступна только роль worker и только его собственные мастера (чужие организации не видны). С параметром q (минимум 2 символа) возвращается поиск по имени/email среди активных — так клиент пригласит в проект существующего мастера, не видя при этом полного списка. include_inactive=1 дополняет список отключёнными аккаунтами — только для админа.
// @Tags users
// @Produce json
// @Security Bearer
// @Param role query string false "Роль" Enums(worker, admin, client) Default(worker)
// @Param q query string false "Поиск по имени или email, минимум 2 символа"
// @Param include_inactive query string false "Показать и отключённых (только для админа)" Enums(0,1) Default(0)
// @Param limit query int false "Строк на странице, 1..100" default(20)
// @Param offset query int false "Сдвиг от начала выборки" default(0)
// @Param sort query string false "Поле сортировки" Enums(name, created_at, email, id) Default(name)
// @Param order query string false "Направление сортировки" Enums(asc, desc)
// @Success 200 {object} handlers.ProfileListResponse
// @Failure 400 {object} handlers.ErrorResponse "неизвестная роль, параметры страницы или короткий q"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "клиент запрашивает не мастеров"
// @Router /users [get]
func (h *UserHandler) List(c *gin.Context) {
role := c.DefaultQuery("role", "worker")
if role != "worker" && role != "admin" && role != "client" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
return
}
page, ok := parsePage(c, repository.ProfileSorts)
if !ok {
return
}
// Поиск: короткая строка — опечатка в начале ввода, отвечаем сразу,
// не ходя в базу за «всеми подряд». Считаем символы, не байты:
// один кириллический «п» — уже два байта.
q := strings.TrimSpace(c.Query("q"))
if q != "" && utf8.RuneCountInString(q) < 2 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid q"})
return
}
// include_inactive: у админа — обычный список любой роли; у клиента —
// его собственные мастера (включая отключённых, чтобы «Включить»
// после отключения не оказался тупиком). Клиентский поиск q
// в ветке ниже всегда остаётся только по активным.
inactiveQ := c.Query("include_inactive")
includeInactive := inactiveQ == "1" || inactiveQ == "true"
respond := func(profiles []models.Profile, total int) {
if profiles == nil {
profiles = []models.Profile{}
}
c.JSON(http.StatusOK, ProfileListResponse{
Items: profiles, Total: total, Limit: page.Limit, Offset: page.Offset,
})
}
// Клиенту — только мастера: либо свои (без запроса), либо поиском (q).
if c.GetString("role") == "client" {
if role != "worker" {
forbidden(c)
return
}
if q != "" {
profiles, total, err := h.repo.SearchByRole(c.Request.Context(), "worker", q, false, page)
if err != nil {
respondError(c, err)
return
}
respond(profiles, total)
return
}
profiles, total, err := h.repo.ListWorkersForClient(c.Request.Context(), c.GetString("user_id"), includeInactive, page)
if err != nil {
respondError(c, err)
return
}
respond(profiles, total)
return
}
// Админ: q сужает обычный список по роли.
if q != "" {
profiles, total, err := h.repo.SearchByRole(c.Request.Context(), role, q, includeInactive, page)
if err != nil {
respondError(c, err)
return
}
respond(profiles, total)
return
}
profiles, total, err := h.repo.ListByRole(c.Request.Context(), role, includeInactive, page)
if err != nil {
respondError(c, err)
return
}
respond(profiles, total)
}
type setUserReq struct {
// *bool обязателен: на bool с binding:"required" запрос
// «деактивировать» (is_active=false) не прошёл бы валидацию.
IsActive *bool `json:"is_active" binding:"required"`
}
// Update — PATCH /users/:id. Включить или выключить аккаунт.
// Админ — любой; клиент — только мастера, который активный участник
// хотя бы одного его проекта (граница — в SQL репозитория, хендлер
// не решает). Выключенный не может войти (Login проверяет is_active)
// и теряет все открытые сессии немедленно, а не по истечении
// access-токена. Для мастера флаг общий на все его проекты.
//
// @Summary Включить/выключить аккаунт
// @Description Админ меняет любой аккаунт; клиент — только мастера, который активный участник одного из его проектов (иного клиента или чужого мастера не видит — 404). При деактивации все refresh-сессии пользователя закрываются сразу, мастер перестаёт работать во всех проектах. Выключить самого себя нельзя — вы остались бы без входа.
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "ID пользователя (uuid)"
// @Param body body setUserReq true "Новый флаг"
// @Success 200 {object} models.Profile
// @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело; попытка выключить себя"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден (для клиента — не его мастер)"
// @Router /users/{id} [patch]
func (h *UserHandler) Update(c *gin.Context) {
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req setUserReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
if id == c.GetString("user_id") {
// Иначе админ может выключить самого себя и остаться без входа.
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own status"})
return
}
// Клиенту доступен только его мастер (проверяется в SQL: роль worker,
// активное членство в проекте этого клиента) — иному выдаётся 404,
// не 403: не раскрываем, существует ли вообще чужой аккаунт.
var err error
if c.GetString("role") == "client" {
err = h.repo.SetActiveWorkerForClient(c.Request.Context(), id, c.GetString("user_id"), *req.IsActive)
} else {
err = h.repo.SetActive(c.Request.Context(), id, *req.IsActive)
}
if err != nil {
respondError(c, err)
return
}
if !*req.IsActive {
ctx := c.Request.Context()
n, err := h.rtRepo.RevokeAllForUser(ctx, id)
switch {
case err != nil:
slog.Warn("deactivation: failed to revoke sessions", "user_id", id, "err", err)
case n > 0:
slog.Info("deactivation: sessions revoked", "user_id", id, "count", n)
}
}
profile, err := h.repo.GetByID(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, profile)
}
// Delete — DELETE /users/:id. Админ — только «чистый» аккаунт: сессии и
// токены сброса уходят каскадом, но если на пользователя ссылаются заявки,
// проекты или участия — БД не даст удалить строку (409) — в этом случае
// его отключают через PATCH. Клиент — только мастера, состоящего в одном
// из его проектов: членства в проектах клиента удаляются вместе с
// аккаунтом одной транзакцией, а заявки или чужие проекты дают тот же
// 409 (откат — членства не трогаются). Со своей учётной записи
// удалиться нельзя: админ остался бы без входа.
//
// @Summary Удалить пользователя
// @Description Админ удаляет любой «чистый» аккаунт: refresh-сессии и токены сброса удаляются каскадом, но наличие заявок, проектов или участий блокирует удаление (409 user has related records) — в этом случае отключите аккаунт через PATCH /users/{id}, историю не стираем. Клиент удаляет только мастера, состоящего в одном из его проектов: членства клиента в проектах удаляются в той же транзакции, но если на мастера ссылаются заявки либо чужие проекты — тоже 409, ничего не меняется. Чужой мастер и несуществующий id неразличимы (404). Свой id удалить нельзя.
// @Tags users
// @Produce json
// @Security Bearer
// @Param id path string true "ID пользователя (uuid)"
// @Success 200 {object} handlers.OKResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id; нельзя удалить себя"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin или client"
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
// @Failure 409 {object} handlers.ErrorResponse "у пользователя есть связанные записи"
// @Router /users/{id} [delete]
func (h *UserHandler) Delete(c *gin.Context) {
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if id == c.GetString("user_id") {
// Иначе админ удалит сам себя и остался бы без входа.
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete yourself"})
return
}
// Граница клиента (мастер состоит в моём проекте), членства и
// атомарность — в SQL-транзакции репозитория; хендлер лишь выбирает
// ветку по роли.
var err error
if c.GetString("role") == "client" {
err = h.repo.DeleteWorkerForClient(c.Request.Context(), id, c.GetString("user_id"))
} else {
err = h.repo.DeleteProfile(c.Request.Context(), id)
}
if err != nil {
if errors.Is(err, repository.ErrUserHasData) {
c.JSON(http.StatusConflict, gin.H{"error": "user has related records"})
return
}
respondError(c, err)
return
}
c.JSON(http.StatusOK, OKResponse{OK: true})
}
// PasswordReset — POST /users/:id/password-reset. Выпустить токен смены.
//
// @Summary Выдать токен сброса пароля
// @Description Токен возвращается в ответе — админ передаёт его пользователю сам, любым каналом. Для пользователя есть почта: POST /auth/forgot-password шлёт письмо со ссылкой. Токен одноразовый, живёт 24 часа (repository.ResetTTL); выпуск нового гасит прежние неиспользованные токены пользователя (включая свежие из писем). В БД хранится только sha256 токена.
// @Tags users
// @Produce json
// @Security Bearer
// @Param id path string true "ID пользователя (uuid)"
// @Success 200 {object} handlers.PasswordResetResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin"
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
// @Router /users/{id}/password-reset [post]
func (h *UserHandler) PasswordReset(c *gin.Context) {
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Проверяем существование до выпуска: для чужого id — 404, а не
// токен на несуществующего пользователя (его бы никто не смог применить).
if _, err := h.repo.GetByID(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
token, expires, err := h.repo.CreateResetToken(c.Request.Context(), id)
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusOK, PasswordResetResponse{OK: true, Token: token, ExpiresAt: expires})
}
type setPasswordReq struct {
Password string `json:"password" binding:"required"`
}
// SetPassword — POST /users/:id/password-set. Задать пароль напрямую.
//
// @Summary Задать пароль напрямую
// @Description Админ ставит пароль пользователю сам, без ссылки. Политика та же, что при создании и при сбросе по токену (10..72 символа, буквы и цифры). Успех закрывает все refresh-сессии пользователя и гасит неиспользованные токены сброса — со старым паролем в системе остаться нельзя, старые ссылки перестают работать. Альтернатива — одноразовая ссылка через POST /users/{id}/password-reset.
// @Tags users
// @Accept json
// @Produce json
// @Security Bearer
// @Param id path string true "ID пользователя (uuid)"
// @Param body body setPasswordReq true "Новый пароль"
// @Success 200 {object} handlers.OKResponse
// @Failure 400 {object} handlers.ErrorResponse "некорректный id, тело или слабый пароль"
// @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен"
// @Failure 403 {object} handlers.ErrorResponse "нужна роль admin"
// @Failure 404 {object} handlers.ErrorResponse "пользователь не найден"
// @Router /users/{id}/password-set [post]
func (h *UserHandler) SetPassword(c *gin.Context) {
id := c.Param("id")
if _, err := uuid.Parse(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req setPasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
bindingError(c, err)
return
}
if err := auth.ValidatePassword(req.Password); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash failed"})
return
}
ctx := c.Request.Context()
// Смена и гашение токенов — одна операция репозитория; 404 на
// несуществующий id отдаёт respondError.
if _, err := h.repo.SetPassword(ctx, id, hash); err != nil {
respondError(c, err)
return
}
// Сессии закрываем, как при сбросе по токену: прежний владелец не
// должен остаться в системе с refresh-сессией, а access-токен живёт
// до часа. Ошибка разлогина — не повод отдавать 500: пароль-то уже
// обновлён, деталь уходит в лог.
if n, err := h.rtRepo.RevokeAllForUser(ctx, id); err != nil {
slog.Warn("password set: failed to revoke sessions", "user_id", id, "err", err)
} else {
slog.Info("security: password set by admin", "user_id", id,
"admin_id", c.GetString("user_id"), "sessions_revoked", n)
}
c.JSON(http.StatusOK, OKResponse{OK: true})
}