Files
paliad/internal/services/team_service.go

279 lines
10 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"
"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
}