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.
387 lines
15 KiB
Go
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
|
|
}
|