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}) }