axe/internal/handlers/users.go

518 lines
27 KiB
Go
Raw Normal View History

2026-10-18 19:07:25 +00:00
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})
}