Files
paliad/internal/services/derivation_service.go
mAi 65308651dd fix(projects): three project-detail page hotfixes
m hit a cluster of three bugs on /projects/{id}/submissions:

1. 500 on /api/projects/{id}/partner-units — DerivationService.AttachedUnit
   scanned derive_unit_roles (text[]) into a plain []string. sqlx returns
   []uint8 for array columns without an adapter. Swap to pq.StringArray
   (same shape as the other array-scanned types in the codebase).

2. 404 on /projects/{id}/submissions — every other project-tab path
   (history, deadlines, team, checklists, …) is registered in handlers.go
   routing all to handleProjectsDetailPage so deep links work, but the
   submissions tab added in t-paliad-230 never got the matching route.
   Result: m navigates to the share-able URL and gets the 404 chrome.
   Add the missing route entry.

3. Create / update project rejected by projekte_client_number_check —
   the CHECK is `client_number IS NULL OR matches '^[0-9]{6}$'`, but the
   form sends empty string "" for an unset field. The Create path passed
   `*input.ClientNumber` raw; the Update path's appendSetSkippable did
   the same. Both now route through a new nullableTrimmed helper that
   coerces empty/whitespace to nil → SQL NULL → constraint accepts.
   matter_number gets the same treatment for symmetry.

Verified the SQL by EXPLAIN against the live DB on the today-filter
hotfix (becf4f0). These three fixes only change Go-side type / nil-
coercion, so no SQL-syntax exposure.
2026-05-22 15:48:47 +02:00

387 lines
15 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"`
// 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
}