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)".
453 lines
17 KiB
Go
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
|
|
}
|