#48 — adds 'admin' as fifth project_teams.responsibility value, plumbs an inheritable role-edit gate via the materialised ltree path. - migration 110: ALTER responsibility CHECK, CREATE paliad.effective_project_admin(uuid,uuid) STABLE SECURITY DEFINER (mirrors can_see_project shape), REPLACE project_teams_update / _insert / _delete RLS policies. Idempotent + down-mig provided. Dry-run BEGIN..ROLLBACK clean on live supabase. - services/approval_levels.go: ResponsibilityAdmin const + IsValidResponsibility extension. responsibilityOpensGate UNCHANGED — admin is orthogonal to the 4-Augen approval gate. - services/team_service.go: ChangeResponsibility() with last-admin guard inside tx (counts admins on project + ancestor chain, excludes the row being changed). RemoveMember() also runs the guard when removing an admin row. New IsEffectiveProjectAdmin() driving the frontend affordance. legacyRoleFromResponsibility: admin → 'lead' (deprecated shadow column). - services/project_service.go: ErrLastProjectAdmin sentinel mapped to 409 in writeServiceError. - handlers/teams.go: new PATCH /api/projects/{id}/team/{user_id}. RLS-enforced; non-admins get 404 to avoid existence leakage. - handlers/projects.go: GET /api/projects/{id} now wraps the payload with effective_admin bool so the frontend drives the inline-select affordance without a second round-trip. - frontend/src/projects-detail.tsx + client/projects-detail.ts: admin appears as 5th option in 'Mitglied hinzufügen' dropdown. Team-list Rolle cell switches to an inline <select> for callers with effective_admin (read-only span otherwise). Optimistic PATCH with rollback on error (last-admin guard / 403 from RLS / etc.) surfaced as transient toast in #team-msg. - i18n: +6 keys (admin label + admin.hint + 3 error toasts × 2 langs). - tests: TestIsValidResponsibility now covers admin; new TestLegacyRoleFromResponsibility pins the mapping table. go build && go test -short ./internal/... && bun run build all clean.
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
package handlers
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// GET /api/projects/{id}/team — returns direct + inherited team members.
|
||
// inherited=true rows include inherited_from_id / inherited_from_title.
|
||
func handleListProjectTeam(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
id, err := uuid.Parse(r.PathValue("id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||
return
|
||
}
|
||
rows, err := dbSvc.team.ListEffectiveMembers(r.Context(), uid, id)
|
||
if err != nil {
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, rows)
|
||
}
|
||
|
||
// POST /api/projects/{id}/team — add a direct member.
|
||
// Body: {"user_id": "<uuid>", "responsibility": "<lead|member|observer|external>"}
|
||
//
|
||
// Legacy clients that submit `role` are still accepted as a synonym
|
||
// during the deprecation window; the field is treated as a
|
||
// responsibility value when the new field is absent.
|
||
func handleAddProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||
return
|
||
}
|
||
var body struct {
|
||
UserID uuid.UUID `json:"user_id"`
|
||
Responsibility string `json:"responsibility"`
|
||
// Legacy field, accepted for one release while frontend migrates.
|
||
Role string `json:"role"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||
return
|
||
}
|
||
resp := body.Responsibility
|
||
if resp == "" {
|
||
resp = body.Role
|
||
}
|
||
m, err := dbSvc.team.AddMember(r.Context(), uid, projectID, body.UserID, resp)
|
||
if err != nil {
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, m)
|
||
}
|
||
|
||
// GET /api/team/memberships — bulk index of project_teams membership for
|
||
// every (visible) user × project pair. Powers the /team page project-
|
||
// multi-select filter (t-paliad-147 / issue #7). Cheap to call: one
|
||
// scan per call; client-side filter handles everything from there.
|
||
func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
rows, err := dbSvc.team.ListMembershipsIndex(r.Context(), uid)
|
||
if err != nil {
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, rows)
|
||
}
|
||
|
||
// PATCH /api/projects/{id}/team/{user_id} — change a direct member's
|
||
// responsibility. Body: {"responsibility": "<admin|lead|member|observer|external>"}.
|
||
//
|
||
// Authorisation is RLS-enforced (project_teams_update gated on
|
||
// effective_project_admin in mig 111). Non-admins get a pq permission
|
||
// error from the UPDATE; we surface that as 404 to avoid leaking that
|
||
// the row exists. The last-admin guard runs inside the service tx and
|
||
// returns ErrLastProjectAdmin (mapped to 409 by writeServiceError).
|
||
func handleChangeProjectTeamMemberResponsibility(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||
return
|
||
}
|
||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||
return
|
||
}
|
||
var body struct {
|
||
Responsibility string `json:"responsibility"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||
return
|
||
}
|
||
m, err := dbSvc.team.ChangeResponsibility(r.Context(), uid, projectID, userID, body.Responsibility)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||
"error": "no direct membership found",
|
||
})
|
||
return
|
||
}
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, m)
|
||
}
|
||
|
||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||
// Inherited memberships can't be removed at the child level.
|
||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||
if !requireDB(w) {
|
||
return
|
||
}
|
||
uid, ok := requireUser(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||
return
|
||
}
|
||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||
if err != nil {
|
||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||
return
|
||
}
|
||
if err := dbSvc.team.RemoveMember(r.Context(), uid, projectID, userID); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||
"error": "no direct membership — inherited memberships must be removed at the ancestor",
|
||
})
|
||
return
|
||
}
|
||
writeServiceError(w, err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|