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" "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. func legacyRoleFromResponsibility(r string) string { switch r { case 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. 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 } res, err := s.db.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 } 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 } // --------------------------------------------------------------------------- // 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 }