Files
paliad/internal/services/team_service.go
mAi 2ed0ef3177 feat(team-admin): t-paliad-223 Slice A — Project Admin role + inheritable role-edit gate
#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.
2026-05-20 14:46:36 +02:00

435 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, &current,
`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 &current, 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
}