package handlers import ( "errors" "log/slog" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/google/uuid" "building-mgmt/internal/models" "building-mgmt/internal/repository" "building-mgmt/internal/storage" ) // ProjectHandler — проекты клиентов, объекты внутри проектов и мастера, // добавленные в проект. Схема: миграция 0004 (проект → объекты, мастера). type ProjectHandler struct { repo *repository.ProjectRepo profileRepo *repository.ProfileRepo // store — объектное хранилище: нужно удалению проекта, чтобы после // коммита убрать объекты фото заявок (сбой — в лог, БД уже согласована). store storage.Store } // NewProjectHandler собирает хендлер: profileRepo нужен, чтобы при // добавлении мастера в проект проверить роль и активность аккаунта, // store — чтобы удаление проекта чистило объекты фото в хранилище. func NewProjectHandler(repo *repository.ProjectRepo, profileRepo *repository.ProfileRepo, store storage.Store) *ProjectHandler { return &ProjectHandler{repo: repo, profileRepo: profileRepo, store: store} } // ownerScope — clientID для проверки владельца: клиенту — свой id, // админу — пусто (видит всё). Остальным ролям — 403 сразу. func ownerScope(c *gin.Context) (string, bool) { switch c.GetString("role") { case "admin": return "", true case "client": return c.GetString("user_id"), true } forbidden(c) return "", false } // pathID — разобрать :id из пути. func pathID(c *gin.Context) (int64, bool) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return 0, false } return id, true } // respondOKProject — вернуть проект; перечитываем, чтобы в ответе были // client_name и подтверждённые БД значения. func (h *ProjectHandler) respondProject(c *gin.Context, id int64) { p, err := h.repo.OwnedProject(c.Request.Context(), id, "") if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, p) } type createProjectReq struct { Name string `json:"name" binding:"required,min=2"` // ClientID — только для админа: для какого клиента создаётся проект. // У клиента владелец берётся из токена, поле игнорируется. ClientID string `json:"client_id"` } // Create — POST /projects. Создать проект, клиент или админ. // // @Summary Создать проект // @Description Клиент создаёт проект себе; админ — для любого клиента (в этом случае обязателен client_id). Проект — контур, внутри которого живут объекты и мастера. // @Tags projects // @Accept json // @Produce json // @Security Bearer // @Param body body createProjectReq true "Данные проекта" // @Success 201 {object} models.Project // @Failure 400 {object} handlers.ErrorResponse "некорректное тело; client_id обязателен для admin; владелец не клиент" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 409 {object} handlers.ErrorResponse "у клиента уже есть проект с таким именем" // @Router /projects [post] func (h *ProjectHandler) Create(c *gin.Context) { scopeID, ok := ownerScope(c) if !ok { return } var req createProjectReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } // Владелец: у клиента — он сам, админ назначает клиента полем client_id. clientID := scopeID if clientID == "" { clientID = req.ClientID if clientID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "client_id is required"}) return } } owner, err := h.profileRepo.GetByID(c.Request.Context(), clientID) if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusBadRequest, gin.H{"error": "client not found"}) return } if err != nil { respondError(c, err) return } if owner.Role != "client" || !owner.IsActive { c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active client"}) return } p := &models.Project{ClientID: clientID, Name: req.Name, IsActive: true} if err := h.repo.CreateProject(c.Request.Context(), p); err != nil { if errors.Is(err, repository.ErrConflict) { c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"}) return } respondError(c, err) return } full, err := h.repo.OwnedProject(c.Request.Context(), p.ID, "") if err != nil { c.JSON(http.StatusCreated, p) return } c.JSON(http.StatusCreated, full) } // List — GET /projects. Список проектов, выборка режется по роли. // // @Summary Список проектов // @Description Клиент видит только свои проекты, админ — все (можно сузить через client_id). Мастерам проекты не отдаются. Ответ — страница: items плюс total/limit/offset. // @Tags projects // @Produce json // @Security Bearer // @Param client_id query string false "Фильтр по клиенту (только admin)" // @Param limit query int false "Строк на странице, 1..100" default(20) // @Param offset query int false "Сдвиг от начала выборки" default(0) // @Param sort query string false "Поле сортировки" Enums(created_at, name, id) Default(created_at) // @Param order query string false "Направление сортировки" Enums(asc, desc) // @Success 200 {object} handlers.ProjectListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Router /projects [get] func (h *ProjectHandler) List(c *gin.Context) { scopeID, ok := ownerScope(c) if !ok { return } if scopeID == "" { if q := c.Query("client_id"); q != "" { scopeID = q } } page, ok := parsePage(c, repository.ProjectSorts) if !ok { return } projects, total, err := h.repo.ListProjects(c.Request.Context(), scopeID, page) if err != nil { respondError(c, err) return } if projects == nil { projects = []models.Project{} } c.JSON(http.StatusOK, ProjectListResponse{ Items: projects, Total: total, Limit: page.Limit, Offset: page.Offset, }) } // Get — GET /projects/:id. Детали проекта. // // @Summary Детали проекта // @Description Админ — любой проект, клиент — только свой; чужой проект неотличим от несуществующего (404). // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Success 200 {object} models.Project // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден" // @Router /projects/{id} [get] func (h *ProjectHandler) Get(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID) if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, p) } type updateProjectReq struct { Name *string `json:"name" binding:"omitempty,min=2"` IsActive *bool `json:"is_active"` } // Update — PATCH /projects/:id. Переименование и/или включение-выключение. // // @Summary Изменить проект // @Description Поля опциональны, меняется только переданное. Выключенный проект не принимает новые заявки и объекты. // @Tags projects // @Accept json // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Param body body updateProjectReq true "Что изменить" // @Success 200 {object} models.Project // @Failure 400 {object} handlers.ErrorResponse "некорректный id или пустое тело" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден" // @Failure 409 {object} handlers.ErrorResponse "имя уже занято у этого клиента" // @Router /projects/{id} [patch] func (h *ProjectHandler) Update(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } var req updateProjectReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } if req.Name == nil && req.IsActive == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // Сначала права: чужой проект не должен отличаться от несуществующего. if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } if err := h.repo.UpdateProject(c.Request.Context(), id, req.Name, req.IsActive); err != nil { if errors.Is(err, repository.ErrConflict) { c.JSON(http.StatusConflict, gin.H{"error": "project name already exists"}) return } respondError(c, err) return } h.respondProject(c, id) } // Delete — DELETE /projects/:id. Удалить проект целиком (только админ). // // @Summary Удалить проект // @Description Действие необратимо: вместе с проектом удаляются его объекты, участия мастеров и все заявки (вместе с событиями и фото). Объекты фото в хранилище убираются после успешного удаления в БД. Клиент своих проектов не удаляет — только админ. // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @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 /projects/{id} [delete] func (h *ProjectHandler) Delete(c *gin.Context) { id, ok := pathID(c) if !ok { return } keys, err := h.repo.DeleteProject(c.Request.Context(), id) if err != nil { respondError(c, err) return } // Строки в БД уже удалены (транзакция закоммичена): объекты фото // чистим best-effort — сбой не должен отменить успех операции, // как в фоновой чистке заявок (см. runTicketsCleanup). for _, key := range keys { if err := h.store.Delete(c.Request.Context(), key); err != nil { slog.Warn("project delete: photo object cleanup failed", "key", key, "err", err) } } c.JSON(http.StatusOK, OKResponse{OK: true}) } // ============ объекты ============ // ListObjects — GET /projects/:id/objects. Объекты проекта. // // @Summary Объекты проекта // @Description Здания/корпуса, входящие в проект. Адрес заявки берётся из объекта. // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @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, id) Default(name) // @Param order query string false "Направление сортировки" Enums(asc, desc) // @Success 200 {object} handlers.ObjectListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден" // @Router /projects/{id}/objects [get] func (h *ProjectHandler) ListObjects(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } page, ok := parsePage(c, repository.ObjectSorts) if !ok { return } if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } objects, total, err := h.repo.ListObjects(c.Request.Context(), id, page) if err != nil { respondError(c, err) return } if objects == nil { objects = []models.Object{} } c.JSON(http.StatusOK, ObjectListResponse{ Items: objects, Total: total, Limit: page.Limit, Offset: page.Offset, }) } type createObjectReq struct { Name string `json:"name" binding:"required,min=2"` Address string `json:"address" binding:"required,min=5"` } // CreateObject — POST /projects/:id/objects. Добавить объект в проект. // // @Summary Создать объект // @Description Объект — здание/корпус с адресом; заявки создаются именно на объект. Выключенный проект объектов не принимает. // @Tags projects // @Accept json // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Param body body createObjectReq true "Данные объекта" // @Success 201 {object} models.Object // @Failure 400 {object} handlers.ErrorResponse "некорректный id или тело" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден" // @Failure 409 {object} handlers.ErrorResponse "проект выключен или имя объекта занято" // @Router /projects/{id}/objects [post] func (h *ProjectHandler) CreateObject(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } var req createObjectReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } p, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID) if err != nil { respondError(c, err) return } if !p.IsActive { c.JSON(http.StatusConflict, gin.H{"error": "project is not active"}) return } o := &models.Object{ ProjectID: p.ID, Name: req.Name, Address: req.Address, IsActive: true, } if err := h.repo.CreateObject(c.Request.Context(), o); err != nil { if errors.Is(err, repository.ErrConflict) { c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"}) return } respondError(c, err) return } full, err := h.repo.OwnedObject(c.Request.Context(), o.ID, "") if err != nil { c.JSON(http.StatusCreated, o) return } c.JSON(http.StatusCreated, full) } type updateObjectReq struct { Name *string `json:"name" binding:"omitempty,min=2"` Address *string `json:"address" binding:"omitempty,min=5"` IsActive *bool `json:"is_active"` } // UpdateObject — PATCH /objects/:id. Правка объекта. // // @Summary Изменить объект // @Description Поля опциональны. У уже созданных заявок адрес остаётся тем, что был на момент создания — это осознанно. // @Tags projects // @Accept json // @Produce json // @Security Bearer // @Param id path int true "ID объекта" // @Param body body updateObjectReq true "Что изменить" // @Success 200 {object} models.Object // @Failure 400 {object} handlers.ErrorResponse "некорректный id или пустое тело" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "объект не найден" // @Failure 409 {object} handlers.ErrorResponse "имя уже занято в проекте" // @Router /objects/{id} [patch] func (h *ProjectHandler) UpdateObject(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } var req updateObjectReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } if req.Name == nil && req.Address == nil && req.IsActive == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // Права: чужой объект неотличим от несуществующего. if _, err := h.repo.OwnedObject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } if err := h.repo.UpdateObject(c.Request.Context(), id, req.Name, req.Address, req.IsActive); err != nil { if errors.Is(err, repository.ErrConflict) { c.JSON(http.StatusConflict, gin.H{"error": "object name already exists"}) return } respondError(c, err) return } full, err := h.repo.OwnedObject(c.Request.Context(), id, "") if err != nil { respondError(c, err) return } c.JSON(http.StatusOK, full) } // ============ мастера проекта ============ // ListWorkers — GET /projects/:id/workers. Мастера проекта. // // @Summary Мастера проекта // @Description Кто приглашён в проект и кем принят: статус invited — приглашение ждёт решения, active — мастер работает с заявками, declined — отказал. Хеши паролей в ответ не попадают. // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @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.ProjectWorkerListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id или параметры страницы" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден" // @Router /projects/{id}/workers [get] func (h *ProjectHandler) ListWorkers(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } page, ok := parsePage(c, repository.ProfileSorts) if !ok { return } if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } workers, total, err := h.repo.ListWorkers(c.Request.Context(), id, page) if err != nil { respondError(c, err) return } if workers == nil { workers = []models.ProjectWorker{} } c.JSON(http.StatusOK, ProjectWorkerListResponse{ Items: workers, Total: total, Limit: page.Limit, Offset: page.Offset, }) } type addWorkerReq struct { WorkerID string `json:"worker_id" binding:"required,uuid"` } // AddWorker — POST /projects/:id/workers. Пригласить мастера в проект. // // @Summary Пригласить мастера в проект // @Description С 0005 это приглашение, а не готовое членство: мастер становится участником только сам, приняв приглашение (POST /projects/{id}/invitation/accept) — до этого заявки проекта ему не назначаются. Приглашение живёт 7 дней. Мастер может состоять в нескольких проектах; аккаунт должен быть активным, с ролью worker и подтверждённым (0010) — неподтверждённого сначала подтверждает тот, кто его создал. // @Tags projects // @Accept json // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Param body body addWorkerReq true "Мастер" // @Success 200 {object} handlers.InvitationResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id/тело; не мастер, неактивен или не подтверждён" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект или мастер не найден" // @Failure 409 {object} handlers.ErrorResponse "мастер уже состоит в проекте / приглашение уже ждёт ответа" // @Router /projects/{id}/workers [post] func (h *ProjectHandler) AddWorker(c *gin.Context) { id, ok := pathID(c) if !ok { return } scopeID, ok := ownerScope(c) if !ok { return } var req addWorkerReq if err := c.ShouldBindJSON(&req); err != nil { bindingError(c, err) return } if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } // Приглашаем только реального активного мастера — как в assign. worker, err := h.profileRepo.GetByID(c.Request.Context(), req.WorkerID) if errors.Is(err, repository.ErrNotFound) { c.JSON(http.StatusBadRequest, gin.H{"error": "worker not found"}) return } if err != nil { respondError(c, err) return } if worker.Role != "worker" || !worker.IsActive { c.JSON(http.StatusBadRequest, gin.H{"error": "user is not an active worker"}) return } // Неподтверждённому приглашение не шлём (0010): он всё равно не // сможет войти и принять его — сначала подтверждение. if worker.ConfirmedAt == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "user is not confirmed"}) return } expires, err := h.repo.InviteWorker(c.Request.Context(), id, req.WorkerID, c.GetString("user_id")) if err != nil { switch { case errors.Is(err, repository.ErrMembershipActive): c.JSON(http.StatusConflict, gin.H{"error": "worker is already in the project"}) case errors.Is(err, repository.ErrInvitationPending): c.JSON(http.StatusConflict, gin.H{"error": "invitation is already pending"}) default: respondError(c, err) } return } c.JSON(http.StatusOK, InvitationResponse{OK: true, Status: "invited", ExpiresAt: expires}) } // AcceptInvitation — POST /projects/:id/invitation/accept. Мастер принял. // // @Summary Принять приглашение в проект // @Description invited → active: с этого момента мастер состоит в проекте и ему можно назначать заявки. Действует только на своё непросроченное приглашение (срок — 7 дней, см. repository.InvitationTTL). // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Success 200 {object} handlers.OKResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" // @Failure 404 {object} handlers.ErrorResponse "приглашения нет" // @Failure 409 {object} handlers.ErrorResponse "срок вышел или приглашение уже закрыто" // @Router /projects/{id}/invitation/accept [post] func (h *ProjectHandler) AcceptInvitation(c *gin.Context) { if !h.respondInvitation(c, func(id int64, workerID string) error { return h.repo.AcceptInvitation(c.Request.Context(), id, workerID) }) { return } c.JSON(http.StatusOK, OKResponse{OK: true}) } // DeclineInvitation — POST /projects/:id/invitation/decline. Мастер отказал. // // @Summary Отклонить приглашение в проект // @Description invited → declined: заявок мастер не получает, клиент видит отказ в составе проекта и может пригласить повторно (строка та же, история ответа обновляется). // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Success 200 {object} handlers.OKResponse // @Failure 400 {object} handlers.ErrorResponse "некорректный id" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" // @Failure 404 {object} handlers.ErrorResponse "приглашения нет" // @Failure 409 {object} handlers.ErrorResponse "приглашение уже закрыто" // @Router /projects/{id}/invitation/decline [post] func (h *ProjectHandler) DeclineInvitation(c *gin.Context) { if !h.respondInvitation(c, func(id int64, workerID string) error { return h.repo.DeclineInvitation(c.Request.Context(), id, workerID) }) { return } c.JSON(http.StatusOK, OKResponse{OK: true}) } // respondInvitation — общий каркас accept/decline: разбор :id, роль // (только мастер, её ставит RequireRole), вызов репозитория и перевод // ошибок приглашения в ответы. true — операция прошла, можно отдавать 200. func (h *ProjectHandler) respondInvitation(c *gin.Context, run func(id int64, workerID string) error) bool { id, ok := pathID(c) if !ok { return false } if err := run(id, c.GetString("user_id")); err != nil { switch { case errors.Is(err, repository.ErrInvitationExpired): c.JSON(http.StatusConflict, gin.H{"error": "invitation expired"}) case errors.Is(err, repository.ErrInvitationNotPending): c.JSON(http.StatusConflict, gin.H{"error": "invitation is not pending"}) default: respondError(c, err) } return false } return true } // ListInvitations — GET /invitations. Приглашения, ждущие решения мастера. // // @Summary Мои приглашения в проекты // @Description Список приглашений для текущего мастера: по каждому — проект, кто пригласил и до какого срока. Просроченные сюда не попадают — принять их уже нельзя. // @Tags projects // @Produce json // @Security Bearer // @Param limit query int false "Строк на странице, 1..100" default(20) // @Param offset query int false "Сдвиг от начала выборки" default(0) // @Param sort query string false "Поле сортировки" Enums(invited_at, expires_at, project_name) Default(invited_at) // @Param order query string false "Направление сортировки" Enums(asc, desc) // @Success 200 {object} handlers.InvitationListResponse // @Failure 400 {object} handlers.ErrorResponse "некорректные параметры страницы" // @Failure 401 {object} handlers.ErrorResponse "нет или неверный токен" // @Failure 403 {object} handlers.ErrorResponse "нужна роль worker" // @Router /invitations [get] func (h *ProjectHandler) ListInvitations(c *gin.Context) { page, ok := parsePage(c, repository.InvitationSorts) if !ok { return } invites, total, err := h.repo.ListInvitations(c.Request.Context(), c.GetString("user_id"), page) if err != nil { respondError(c, err) return } if invites == nil { invites = []models.ProjectInvitation{} } c.JSON(http.StatusOK, InvitationListResponse{ Items: invites, Total: total, Limit: page.Limit, Offset: page.Offset, }) } // RemoveWorker — DELETE /projects/:id/workers/:worker_id. Убрать мастера. // // @Summary Убрать мастера из проекта // @Description Нельзя убрать, пока за ним закреплены незакрытые заявки проекта (409) — иначе работа осталась бы без исполнителя. Заявки из истории при этом не теряются. // @Tags projects // @Produce json // @Security Bearer // @Param id path int true "ID проекта" // @Param worker_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 "нужна роль client или admin" // @Failure 404 {object} handlers.ErrorResponse "проект не найден или мастер не состоит в проекте" // @Failure 409 {object} handlers.ErrorResponse "за мастером есть незакрытые заявки" // @Router /projects/{id}/workers/{worker_id} [delete] func (h *ProjectHandler) RemoveWorker(c *gin.Context) { id, ok := pathID(c) if !ok { return } workerID := c.Param("worker_id") if _, err := uuid.Parse(workerID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) return } scopeID, ok := ownerScope(c) if !ok { return } if _, err := h.repo.OwnedProject(c.Request.Context(), id, scopeID); err != nil { respondError(c, err) return } if err := h.repo.RemoveWorker(c.Request.Context(), id, workerID); err != nil { if errors.Is(err, repository.ErrConflict) { c.JSON(http.StatusConflict, gin.H{"error": "worker has open tickets in this project"}) return } respondError(c, err) return } c.JSON(http.StatusOK, OKResponse{OK: true}) }