#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.
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package services
|
||
|
||
// TeamService manages paliad.project_teams — project team memberships.
|
||
//
|
||
// Inheritance model (t-paliad-024): a user added at any ancestor of a Project
|
||
// is implicitly a member of every descendant. Writes only ever touch the
|
||
// direct level; inherited memberships are computed at read time by walking
|
||
// UP the materialised path.
|
||
//
|
||
// The `inherited` column in the DB is reserved for potential future caching
|
||
// of inherited rows. This service does not write inherited=true rows.
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jmoiron/sqlx"
|
||
"github.com/lib/pq"
|
||
|
||
"mgit.msbls.de/m/paliad/internal/models"
|
||
)
|
||
|
||
// TeamService reads and writes paliad.project_teams.
|
||
type TeamService struct {
|
||
db *sqlx.DB
|
||
projects *ProjectService
|
||
}
|
||
|
||
// NewTeamService wires the service.
|
||
func NewTeamService(db *sqlx.DB, projects *ProjectService) *TeamService {
|
||
return &TeamService{db: db, projects: projects}
|
||
}
|
||
|
||
// AddMember inserts a direct team membership. The caller must have
|
||
// visibility on the Project (RLS + service-layer gate). Responsibility
|
||
// defaults to 'member' if empty. Idempotent on (project_id, user_id) —
|
||
// a repeat call updates the responsibility.
|
||
//
|
||
// t-paliad-148: this method writes the per-project responsibility only.
|
||
// The user's firm-level profession is NEVER touched here — it lives on
|
||
// paliad.users.profession and is set during onboarding / by global_admin
|
||
// via /admin/team. The legacy `role` column is kept synchronised
|
||
// (mapped from the responsibility) until migration 058 drops it.
|
||
func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID uuid.UUID, responsibility string) (*models.ProjectTeamMember, error) {
|
||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||
return nil, err
|
||
}
|
||
if responsibility == "" {
|
||
responsibility = ResponsibilityMember
|
||
}
|
||
if !IsValidResponsibility(responsibility) {
|
||
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, responsibility)
|
||
}
|
||
|
||
// Map responsibility → legacy role for the deprecated shadow column.
|
||
// Drop this mapping when migration 058 removes the column.
|
||
legacyRole := legacyRoleFromResponsibility(responsibility)
|
||
|
||
var m models.ProjectTeamMember
|
||
err := s.db.GetContext(ctx, &m,
|
||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||
VALUES ($1, $2, $3, $4, false, $5)
|
||
ON CONFLICT (project_id, user_id) DO UPDATE
|
||
SET role = EXCLUDED.role,
|
||
responsibility = EXCLUDED.responsibility
|
||
RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
|
||
projectID, userID, legacyRole, responsibility, callerID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("add team member: %w", err)
|
||
}
|
||
return &m, nil
|
||
}
|
||
|
||
// legacyRoleFromResponsibility maps the new project-responsibility value
|
||
// to the closest legacy project_teams.role value, so the deprecated
|
||
// shadow column stays consistent. Drop when migration 058 retires the
|
||
// column. external → 'local_counsel' is intentionally narrower than the
|
||
// new enum (loses the expert distinction); we accept that for the short
|
||
// transition window.
|
||
//
|
||
// ResponsibilityAdmin (t-paliad-223) maps to legacy 'lead' — the closest
|
||
// legacy match. The legacy column is dead either way; the mapping is
|
||
// purely cosmetic until the column is dropped.
|
||
func legacyRoleFromResponsibility(r string) string {
|
||
switch r {
|
||
case ResponsibilityAdmin, ResponsibilityLead:
|
||
return "lead"
|
||
case ResponsibilityObserver:
|
||
return "observer"
|
||
case ResponsibilityExternal:
|
||
return "local_counsel"
|
||
default:
|
||
// 'member' has no single legacy mapping — pick 'associate' (the
|
||
// default the legacy code used). Real authority comes from
|
||
// users.profession now, so this label is purely cosmetic.
|
||
return "associate"
|
||
}
|
||
}
|
||
|
||
// RemoveMember deletes a direct team membership. Inherited memberships (from
|
||
// ancestors) can't be removed at the child level — the caller must remove
|
||
// the ancestor row to break the inheritance.
|
||
//
|
||
// t-paliad-223 last-admin guard: if the row being removed carries
|
||
// responsibility='admin', refuse when it would leave the project + its
|
||
// ancestor chain with zero admins. Wrapped in a tx so the count + delete
|
||
// are atomic; ErrLastProjectAdmin bubbles up unchanged for the handler
|
||
// to map to 409.
|
||
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
|
||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||
return err
|
||
}
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Look up the row first so we know whether to run the guard.
|
||
var existing models.ProjectTeamMember
|
||
if err := tx.GetContext(ctx, &existing,
|
||
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
|
||
FROM paliad.project_teams
|
||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||
projectID, userID); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return sql.ErrNoRows
|
||
}
|
||
return fmt.Errorf("lookup team member: %w", err)
|
||
}
|
||
|
||
if existing.Responsibility == ResponsibilityAdmin {
|
||
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`DELETE FROM paliad.project_teams
|
||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||
projectID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("remove team member: %w", err)
|
||
}
|
||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||
return sql.ErrNoRows
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("commit remove team member: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ChangeResponsibility updates a direct team member's responsibility.
|
||
// RLS enforces the authorisation (only effective_project_admin can pass
|
||
// the project_teams_update WITH CHECK); this method handles validation
|
||
// + the last-admin guard when the change is AWAY from admin.
|
||
//
|
||
// Inherited rows can't be edited here — the caller must change the
|
||
// ancestor row. Trying to update an inherited row returns sql.ErrNoRows.
|
||
func (s *TeamService) ChangeResponsibility(ctx context.Context, callerID, projectID, userID uuid.UUID, newResponsibility string) (*models.ProjectTeamMember, error) {
|
||
if !IsValidResponsibility(newResponsibility) {
|
||
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, newResponsibility)
|
||
}
|
||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
tx, err := s.db.BeginTxx(ctx, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("begin tx: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Read current row so we know whether the guard needs to fire and so
|
||
// we can short-circuit no-op writes.
|
||
var current models.ProjectTeamMember
|
||
if err := tx.GetContext(ctx, ¤t,
|
||
`SELECT id, project_id, user_id, role, responsibility, inherited, added_by, created_at
|
||
FROM paliad.project_teams
|
||
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
|
||
projectID, userID); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, sql.ErrNoRows
|
||
}
|
||
return nil, fmt.Errorf("lookup team member: %w", err)
|
||
}
|
||
if current.Responsibility == newResponsibility {
|
||
// No-op; commit the empty tx so caller still gets a typed result.
|
||
_ = tx.Commit()
|
||
return ¤t, nil
|
||
}
|
||
|
||
if current.Responsibility == ResponsibilityAdmin && newResponsibility != ResponsibilityAdmin {
|
||
if err := assertProjectKeepsAdmin(ctx, tx, projectID, userID); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
legacyRole := legacyRoleFromResponsibility(newResponsibility)
|
||
|
||
var updated models.ProjectTeamMember
|
||
if err := tx.GetContext(ctx, &updated,
|
||
`UPDATE paliad.project_teams
|
||
SET responsibility = $3, role = $4
|
||
WHERE project_id = $1 AND user_id = $2 AND inherited = false
|
||
RETURNING id, project_id, user_id, role, responsibility, inherited, added_by, created_at`,
|
||
projectID, userID, newResponsibility, legacyRole); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return nil, sql.ErrNoRows
|
||
}
|
||
return nil, fmt.Errorf("change responsibility: %w", err)
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return nil, fmt.Errorf("commit change responsibility: %w", err)
|
||
}
|
||
return &updated, nil
|
||
}
|
||
|
||
// assertProjectKeepsAdmin returns ErrLastProjectAdmin iff removing the
|
||
// (projectID, excludeUserID) admin row would leave the project's ancestor
|
||
// chain (project + every ancestor up to the root) with zero admins.
|
||
//
|
||
// Counts admin rows on every row in the ancestor chain, excluding the row
|
||
// being changed. Uses the same ltree path-walk as paliad.can_see_project.
|
||
//
|
||
// This is a service-layer guard; we don't put it in an RLS WITH CHECK
|
||
// because the count happens post-mutation in a typical WITH CHECK, and
|
||
// the natural place to express it is here where we already hold the tx.
|
||
func assertProjectKeepsAdmin(ctx context.Context, tx *sqlx.Tx, projectID, excludeUserID uuid.UUID) error {
|
||
var remaining int
|
||
if err := tx.GetContext(ctx, &remaining, `
|
||
SELECT count(*)
|
||
FROM paliad.projects p
|
||
JOIN paliad.project_teams pt
|
||
ON pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||
AND pt.responsibility = 'admin'
|
||
WHERE p.id = $1
|
||
AND NOT (pt.project_id = $1 AND pt.user_id = $2)
|
||
`, projectID, excludeUserID); err != nil {
|
||
return fmt.Errorf("count remaining admins: %w", err)
|
||
}
|
||
if remaining == 0 {
|
||
return ErrLastProjectAdmin
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListDirectMembers returns only the direct (non-inherited) team members,
|
||
// enriched with user display fields.
|
||
func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
|
||
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
|
||
return nil, err
|
||
}
|
||
rows := []models.ProjectTeamMemberWithUser{}
|
||
err := s.db.SelectContext(ctx, &rows,
|
||
`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility, pt.inherited,
|
||
pt.added_by, pt.created_at,
|
||
u.email AS user_email,
|
||
u.display_name AS user_display_name,
|
||
u.office AS user_office,
|
||
u.profession AS user_profession,
|
||
NULL::uuid AS inherited_from_id,
|
||
NULL::text AS inherited_from_title
|
||
FROM paliad.project_teams pt
|
||
LEFT JOIN paliad.users u ON u.id = pt.user_id
|
||
WHERE pt.project_id = $1
|
||
ORDER BY pt.responsibility, u.display_name`, projectID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list direct team: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// ListEffectiveMembers returns direct + inherited members of a Project.
|
||
// Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title.
|
||
// If the same user is both direct and inherited, the direct row wins.
|
||
func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
|
||
project, err := s.projects.GetByID(ctx, callerID, projectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ancestorIDs := pathToIDStrings(project.Path)
|
||
|
||
query := `
|
||
WITH candidate AS (
|
||
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility,
|
||
pt.added_by, pt.created_at,
|
||
(pt.project_id <> $1) AS inherited,
|
||
CASE WHEN pt.project_id <> $1 THEN pt.project_id END AS inherited_from_id,
|
||
CASE WHEN pt.project_id <> $1 THEN parent.title END AS inherited_from_title
|
||
FROM paliad.project_teams pt
|
||
LEFT JOIN paliad.projects parent ON parent.id = pt.project_id
|
||
WHERE pt.project_id = ANY($2::uuid[])
|
||
),
|
||
ranked AS (
|
||
SELECT c.*, ROW_NUMBER() OVER (
|
||
PARTITION BY c.user_id
|
||
ORDER BY c.inherited ASC, c.created_at ASC
|
||
) AS rn FROM candidate c
|
||
)
|
||
SELECT r.id, r.project_id, r.user_id, r.role, r.responsibility, r.inherited,
|
||
r.added_by, r.created_at,
|
||
u.email AS user_email,
|
||
u.display_name AS user_display_name,
|
||
u.office AS user_office,
|
||
u.profession AS user_profession,
|
||
r.inherited_from_id,
|
||
r.inherited_from_title
|
||
FROM ranked r
|
||
LEFT JOIN paliad.users u ON u.id = r.user_id
|
||
WHERE r.rn = 1
|
||
ORDER BY r.inherited ASC, r.responsibility, u.display_name`
|
||
|
||
rows := []models.ProjectTeamMemberWithUser{}
|
||
if err := s.db.SelectContext(ctx, &rows, query, projectID, pq.StringArray(ancestorIDs)); err != nil {
|
||
return nil, fmt.Errorf("list effective team: %w", err)
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
// MembershipEntry is one row in the team-memberships index.
|
||
// Powers the /team page project-multi-select filter (t-paliad-147):
|
||
// the frontend pulls the index once, then filters users locally
|
||
// by intersecting the UI-selected project_ids against each user's
|
||
// project_ids list.
|
||
type MembershipEntry struct {
|
||
UserID uuid.UUID `json:"user_id"`
|
||
ProjectIDs []string `json:"project_ids"`
|
||
// LeadProjectIDs is the subset of project_ids on which this
|
||
// user has role='lead'. Surfaces the "I am a lead on N projects"
|
||
// state the broadcast send-button needs.
|
||
LeadProjectIDs []string `json:"lead_project_ids"`
|
||
// Role on each project — same indexing as project_ids — so the
|
||
// frontend can offer a project_teams.role filter.
|
||
Roles []string `json:"roles"`
|
||
}
|
||
|
||
// ListMembershipsIndex returns one row per user × project_team membership
|
||
// the caller can see. global_admin sees everything; non-admin only sees
|
||
// memberships on projects whose visibility predicate they pass.
|
||
//
|
||
// Membership rows are direct (paliad.project_teams.project_id) only —
|
||
// inherited memberships are left to the client to compute, since the
|
||
// project-multi-select filter wants "user is on this exact project"
|
||
// semantics, not "user inherits from somewhere up the tree".
|
||
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
|
||
rows, err := s.db.QueryContext(ctx, `
|
||
SELECT pt.user_id::text, pt.project_id::text, pt.role
|
||
FROM paliad.project_teams pt
|
||
JOIN paliad.projects p ON p.id = pt.project_id
|
||
WHERE `+visibilityPredicatePositional("p", 1)+`
|
||
ORDER BY pt.user_id, pt.project_id`,
|
||
callerID,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list memberships index: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
byUser := map[uuid.UUID]*MembershipEntry{}
|
||
for rows.Next() {
|
||
var userIDStr, projectIDStr, role string
|
||
if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
|
||
return nil, fmt.Errorf("scan membership: %w", err)
|
||
}
|
||
uid, err := uuid.Parse(userIDStr)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
entry, ok := byUser[uid]
|
||
if !ok {
|
||
entry = &MembershipEntry{UserID: uid}
|
||
byUser[uid] = entry
|
||
}
|
||
entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
|
||
entry.Roles = append(entry.Roles, role)
|
||
if role == RoleLead {
|
||
entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, fmt.Errorf("iter memberships: %w", err)
|
||
}
|
||
out := make([]MembershipEntry, 0, len(byUser))
|
||
for _, e := range byUser {
|
||
out = append(out, *e)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// IsEffectiveProjectAdmin reports whether the user is global_admin OR has
|
||
// responsibility='admin' on the project itself or any ancestor in the
|
||
// materialised ltree path.
|
||
//
|
||
// Delegates to paliad.effective_project_admin SQL (t-paliad-223 mig 111).
|
||
// The function is STABLE SECURITY DEFINER so it sees rows regardless of
|
||
// the caller's RLS context — the boolean answer doesn't leak data.
|
||
//
|
||
// Used by the project-detail handler to drive the inline-select affordance
|
||
// in the team panel: only effective_project_admins see the editable
|
||
// <select>; everyone else sees a read-only <span>.
|
||
func (s *TeamService) IsEffectiveProjectAdmin(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
|
||
var b bool
|
||
if err := s.db.GetContext(ctx, &b,
|
||
`SELECT paliad.effective_project_admin($1, $2)`,
|
||
userID, projectID); err != nil {
|
||
return false, fmt.Errorf("effective_project_admin: %w", err)
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// pathToIDStrings splits a materialised path into its UUID labels as strings,
|
||
// suitable for pq.StringArray → uuid[] cast.
|
||
func pathToIDStrings(path string) []string {
|
||
if path == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(path, ".")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|