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 //