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