package services // DerivationService manages partner-unit derivation onto project teams // (t-paliad-139). It owns the project↔unit junction table // (paliad.project_partner_units) and the read paths the Team tab + the // approval inbox use to compute "who's effectively on this project via a // partner unit". // // Derivation is computed on read (no materialised state). The visibility // predicate paliad.can_see_project (extended in migration 055) is the // authoritative gate for what users can see; this service is the read / // authoring API on top of it. import ( "context" "database/sql" "encoding/json" "errors" "fmt" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/models" ) // DerivationService is the read + authoring path for partner-unit derivation. type DerivationService struct { db *sqlx.DB projects *ProjectService partnerUnit *PartnerUnitService } // NewDerivationService wires the service. func NewDerivationService(db *sqlx.DB, projects *ProjectService, partnerUnit *PartnerUnitService) *DerivationService { return &DerivationService{db: db, projects: projects, partnerUnit: partnerUnit} } // AttachedUnit is one row in paliad.project_partner_units enriched with the // unit's display name + count of members that would currently derive given // the configured derive_unit_roles. The frontend renders this on the // /projects/{id}/settings/team Partner Units section. type AttachedUnit struct { ProjectID uuid.UUID `db:"project_id" json:"project_id"` PartnerUnitID uuid.UUID `db:"partner_unit_id" json:"partner_unit_id"` UnitName string `db:"unit_name" json:"unit_name"` // derive_unit_roles is a Postgres text[]; sqlx returns it as []byte // without an array adapter, so we use pq.StringArray for the scan // and convert to []string in JSON via a tiny ergonomics wrapper. DeriveUnitRoles pq.StringArray `db:"derive_unit_roles" json:"derive_unit_roles"` DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"` DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"` } // DerivedMembership is one (unit, role) pair through which a user currently // derives onto a project. A multi-unit user has one DerivedMembership per // unit they belong to that's attached to the project (or one of its // ancestors) AND whose unit_role is in the attachment's derive_unit_roles. type DerivedMembership struct { UnitID uuid.UUID `json:"unit_id"` UnitName string `json:"unit_name"` UnitRole string `json:"unit_role"` } // DerivedMembershipList is a []DerivedMembership that scans from a Postgres // jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers). type DerivedMembershipList []DerivedMembership // Scan implements sql.Scanner over a jsonb array. func (l *DerivedMembershipList) Scan(src any) error { if src == nil { *l = nil return nil } var raw []byte switch v := src.(type) { case []byte: raw = v case string: raw = []byte(v) default: return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src) } return json.Unmarshal(raw, (*[]DerivedMembership)(l)) } // DerivedMember is one user who currently derives onto a project. The user // may derive via multiple units (e.g. a PA who works with two partners); // each is one entry in Memberships. DeriveGrantsAuthority is true if any // of the source attachments have authority enabled. type DerivedMember struct { UserID uuid.UUID `db:"user_id" json:"user_id"` Email string `db:"email" json:"user_email"` DisplayName string `db:"display_name" json:"user_display_name"` Office string `db:"office" json:"user_office"` Memberships DerivedMembershipList `db:"memberships" json:"memberships"` DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"` } // AttachUnitOptions controls how a unit is attached. Empty values use the // migration-055 defaults: derive_unit_roles = {pa, senior_pa}, // derive_grants_authority = false (visibility-only). type AttachUnitOptions struct { DeriveUnitRoles []string DeriveGrantsAuthority bool } // requireWritePermission gates project↔unit attach/detach to project lead // or global_admin. Mirrors the RLS write policy in migration 055. func (s *DerivationService) requireWritePermission(ctx context.Context, callerID, projectID uuid.UUID) error { user, err := s.projects.Users().GetByID(ctx, callerID) if err != nil { return err } if user != nil && user.GlobalRole == "global_admin" { return nil } // t-paliad-148: project-management write permission gates on the // project responsibility, not on the (firm-tier) profession. A // partner with responsibility=observer on this matter cannot manage // partner-unit attachments here; conversely a non-partner with // responsibility=lead can. var responsibility string err = s.db.GetContext(ctx, &responsibility, `SELECT responsibility FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`, projectID, callerID) if errors.Is(err, sql.ErrNoRows) { return ErrForbidden } if err != nil { return fmt.Errorf("read project_teams responsibility: %w", err) } if responsibility != ResponsibilityLead { return ErrForbidden } return nil } // AttachUnitToProject creates a project_partner_units row. Idempotent on // (project_id, partner_unit_id) — a repeat call updates the derive options. // Caller must be project lead OR global_admin. func (s *DerivationService) AttachUnitToProject(ctx context.Context, callerID, projectID, unitID uuid.UUID, opts AttachUnitOptions) error { if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil { return err } if err := s.requireWritePermission(ctx, callerID, projectID); err != nil { return err } if _, err := s.partnerUnit.GetByID(ctx, unitID); err != nil { return err } roles := opts.DeriveUnitRoles if len(roles) == 0 { roles = []string{UnitRolePA, UnitRoleSeniorPA} } for _, r := range roles { if !isValidUnitRole(r) { return fmt.Errorf("%w: invalid unit_role %q in derive_unit_roles", ErrInvalidInput, r) } } _, err := s.db.ExecContext(ctx, `INSERT INTO paliad.project_partner_units (project_id, partner_unit_id, derive_unit_roles, derive_grants_authority, attached_at, attached_by) VALUES ($1, $2, $3, $4, now(), $5) ON CONFLICT (project_id, partner_unit_id) DO UPDATE SET derive_unit_roles = EXCLUDED.derive_unit_roles, derive_grants_authority = EXCLUDED.derive_grants_authority`, projectID, unitID, pq.StringArray(roles), opts.DeriveGrantsAuthority, callerID) if err != nil { return fmt.Errorf("attach unit to project: %w", err) } return nil } // DetachUnitFromProject deletes a project_partner_units row. Idempotent — // repeat detach is a no-op. func (s *DerivationService) DetachUnitFromProject(ctx context.Context, callerID, projectID, unitID uuid.UUID) error { if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil { return err } if err := s.requireWritePermission(ctx, callerID, projectID); err != nil { return err } if _, err := s.db.ExecContext(ctx, `DELETE FROM paliad.project_partner_units WHERE project_id = $1 AND partner_unit_id = $2`, projectID, unitID); err != nil { return fmt.Errorf("detach unit from project: %w", err) } return nil } // ListAttachedUnits returns the unit attachments anchored on this exact // project (NOT walking ancestors — the project /settings/team page wants // to manage its own attachments only). Each row is enriched with the unit // name and the count of members that would currently derive given the // configured derive_unit_roles. func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, projectID uuid.UUID) ([]AttachedUnit, error) { if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil { return nil, err } rows := []AttachedUnit{} err := s.db.SelectContext(ctx, &rows, `SELECT ppu.project_id, ppu.partner_unit_id, pu.name AS unit_name, ppu.derive_unit_roles, ppu.derive_grants_authority, (SELECT COUNT(*) FROM paliad.partner_unit_members pum WHERE pum.partner_unit_id = ppu.partner_unit_id AND pum.unit_role = ANY(ppu.derive_unit_roles)) AS derived_member_count FROM paliad.project_partner_units ppu JOIN paliad.partner_units pu ON pu.id = ppu.partner_unit_id WHERE ppu.project_id = $1 ORDER BY pu.name`, projectID) if err != nil { return nil, fmt.Errorf("list attached units: %w", err) } return rows, nil } // ListDerivedMembers returns users who currently derive onto this project // via any attached unit on the project's path (this project + ancestors). // Walks UP the path because a unit attached at the Client level cascades // down to descendants — derivation honours the same direction as // can_see_project. // // One row per user. Multi-unit users (e.g. a PA working across two partner // units, both of which are attached to the project's path) carry every // (unit, role) pair in Memberships so the Herkunft column can list them // all (t-paliad-143). DeriveGrantsAuthority is bool_or across the // underlying attachments — a user with at least one authority-granting // derivation source qualifies as authority-bearing for approval purposes. func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) { project, err := s.projects.GetByID(ctx, callerID, projectID) if err != nil { return nil, err } ancestorIDs := pathToIDStrings(project.Path) if len(ancestorIDs) == 0 { return []DerivedMember{}, nil } rows := []DerivedMember{} err = s.db.SelectContext(ctx, &rows, ` WITH attached AS ( SELECT ppu.project_id AS attach_project_id, ppu.partner_unit_id, ppu.derive_unit_roles, ppu.derive_grants_authority FROM paliad.project_partner_units ppu WHERE ppu.project_id = ANY($1::uuid[]) ) SELECT pum.user_id, u.email, u.display_name, u.office, jsonb_agg(DISTINCT jsonb_build_object( 'unit_id', a.partner_unit_id, 'unit_name', pu.name, 'unit_role', pum.unit_role )) AS memberships, bool_or(a.derive_grants_authority) AS derive_grants_authority FROM attached a JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id JOIN paliad.users u ON u.id = pum.user_id JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id WHERE pum.unit_role = ANY(a.derive_unit_roles) GROUP BY pum.user_id, u.email, u.display_name, u.office ORDER BY u.display_name`, pq.StringArray(ancestorIDs)) if err != nil { return nil, fmt.Errorf("list derived members: %w", err) } // jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call. // Sort each member's memberships by unit_name in Go so the Herkunft // column renders deterministically. for i := range rows { ms := rows[i].Memberships for j := 1; j < len(ms); j++ { for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- { ms[k-1], ms[k] = ms[k], ms[k-1] } } } return rows, nil } // ListDescendantStaffed returns users who are directly staffed on a // descendant of the given project but not on the project itself or its // ancestors. This is the new "Aus Unterprojekten" subsection on the Team // tab — explicit Case-level staff that surfaces up to the parent for // awareness. // // Excludes inherited rows (descendant team rows are by definition direct // at their level — what we filter out are users already on this project // or its ancestors so the same user doesn't appear in two subsections). func (s *DerivationService) ListDescendantStaffed(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, ` WITH descendants AS ( SELECT p.id, p.title FROM paliad.projects p WHERE p.id <> $1 AND $1 = ANY(string_to_array(p.path, '.')::uuid[]) ), ancestor_or_self AS ( SELECT pp.id FROM paliad.projects target JOIN paliad.projects pp ON pp.id = ANY(string_to_array(target.path, '.')::uuid[]) WHERE target.id = $1 ), descendant_rows AS ( SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.responsibility, pt.added_by, pt.created_at, d.title AS source_title FROM paliad.project_teams pt JOIN descendants d ON d.id = pt.project_id WHERE pt.user_id NOT IN ( SELECT user_id FROM paliad.project_teams WHERE project_id IN (SELECT id FROM ancestor_or_self) ) ), dedup AS ( SELECT dr.*, ROW_NUMBER() OVER ( PARTITION BY dr.user_id ORDER BY dr.created_at ASC ) AS rn FROM descendant_rows dr ) SELECT d.id, d.project_id, d.user_id, d.role, d.responsibility, true AS inherited, d.added_by, d.created_at, u.email AS user_email, u.display_name AS user_display_name, u.office AS user_office, u.profession AS user_profession, d.project_id AS inherited_from_id, d.source_title AS inherited_from_title FROM dedup d JOIN paliad.users u ON u.id = d.user_id WHERE d.rn = 1 ORDER BY d.responsibility, u.display_name`, projectID) if err != nil { return nil, fmt.Errorf("list descendant-staffed: %w", err) } return rows, nil } // UserProjectAuthorityLevel returns the effective approval-ladder level // for user U on project P, evaluated as a tuple-with-gate: // // profession_level = approval_role_level(U.profession) // 0 if NULL // responsibility = direct or ancestor on project P // gate_open = responsibility IN {lead, member} // derived_role = approval_role_from_unit_role(unit_role) // when grants_authority // level = max( profession_level if gate_open else 0, // derived_role_level ) // // Thin wrapper over paliad.user_project_authority_level — kept here so // any future caller that needs the level without writing raw SQL has a // single helper to call. The ApprovalService SQL paths inline the // computation directly for query efficiency. func (s *DerivationService) UserProjectAuthorityLevel(ctx context.Context, userID, projectID uuid.UUID) (int, error) { var lvl int err := s.db.GetContext(ctx, &lvl, `SELECT paliad.user_project_authority_level($1, $2)`, userID, projectID) if errors.Is(err, sql.ErrNoRows) { return 0, nil } if err != nil { return 0, fmt.Errorf("read user project authority level: %w", err) } return lvl, nil }