Files
paliad/internal/services/derivation_service.go
m bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00

453 lines
17 KiB
Go

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"`
DeriveUnitRoles []string `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
}
var role string
err = s.db.GetContext(ctx, &role,
`SELECT role 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 role: %w", err)
}
if role != RoleLead {
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.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,
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,
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.role, u.display_name`,
projectID)
if err != nil {
return nil, fmt.Errorf("list descendant-staffed: %w", err)
}
return rows, nil
}
// EffectiveProjectRole returns (role, source) where source is one of:
// 'direct', 'ancestor', 'descendant', 'derived'. Used by the t-138
// approval ladder via canApprove() — Phase 3 of t-paliad-139 will wire
// this in.
//
// Resolution order:
// 1. direct (this project_teams row)
// 2. ancestor (project_teams on any ancestor — closest wins)
// 3. derived (partner_unit_members on an attached unit on this project
// or any ancestor — closest wins; only when derive_grants_authority=true)
// 4. descendant (rare for authority — explicit staffing on a descendant
// does NOT confer authority on the ancestor; returned for read use
// only, callers should prefer the higher tiers)
//
// Returns ("", "") when the user has no membership of any kind. This is a
// service-internal lookup — it does NOT visibility-check, since callers
// (the t-138 approval gate) need to know the caller's effective role even
// when visibility is being evaluated for the first time.
func (s *DerivationService) EffectiveProjectRole(ctx context.Context, userID, projectID uuid.UUID) (string, string, error) {
var path string
err := s.db.GetContext(ctx, &path,
`SELECT path FROM paliad.projects WHERE id = $1`, projectID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("read project path: %w", err)
}
ancestorIDs := pathToIDStrings(path)
// 1. Direct
var directRole string
err = s.db.GetContext(ctx, &directRole,
`SELECT role FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`,
projectID, userID)
if err == nil {
return directRole, "direct", nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", fmt.Errorf("read direct role: %w", err)
}
// 2. Ancestor (closest wins via path distance — already root→self order
// in the path; pick the row whose project_id appears latest in the
// ancestorIDs array).
type ancRow struct {
Role string `db:"role"`
ProjID string `db:"project_id"`
Position int `db:"position"`
}
var ancestorMatches []ancRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &ancestorMatches, `
SELECT pt.role,
pt.project_id::text AS project_id,
array_position($1::uuid[], pt.project_id) AS position
FROM paliad.project_teams pt
WHERE pt.user_id = $2
AND pt.project_id = ANY($1::uuid[])
ORDER BY position DESC NULLS LAST
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read ancestor role: %w", err)
}
if len(ancestorMatches) > 0 {
return ancestorMatches[0].Role, "ancestor", nil
}
}
// 3. Derived with authority. Only authority-granting attachments count
// here; visibility-only derivation does not yield an effective role for
// approval purposes. The derived role is mapped from unit_role via
// approval_role_from_unit_role (a SQL function added in migration 055).
type derivedRow struct {
Role string `db:"role"`
}
var derived []derivedRow
if len(ancestorIDs) > 0 {
err = s.db.SelectContext(ctx, &derived, `
SELECT paliad.approval_role_from_unit_role(pum.unit_role) AS role
FROM paliad.project_partner_units ppu
JOIN paliad.partner_unit_members pum
ON pum.partner_unit_id = ppu.partner_unit_id
AND pum.user_id = $2
AND pum.unit_role = ANY(ppu.derive_unit_roles)
WHERE ppu.project_id = ANY($1::uuid[])
AND ppu.derive_grants_authority = true
ORDER BY paliad.approval_role_level(
paliad.approval_role_from_unit_role(pum.unit_role)
) DESC
LIMIT 1`,
pq.StringArray(ancestorIDs), userID)
if err != nil {
return "", "", fmt.Errorf("read derived role: %w", err)
}
if len(derived) > 0 {
return derived[0].Role, "derived", nil
}
}
return "", "", nil
}