Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
588 lines
19 KiB
Go
588 lines
19 KiB
Go
package services
|
|
|
|
// PartnerUnitService handles paliad.partner_units + paliad.partner_unit_members
|
|
// — the structural partner-led units (legacy "Dezernat"). Orthogonal to
|
|
// project teams: a user typically belongs to exactly one PartnerUnit but may
|
|
// work on projects across all of them.
|
|
//
|
|
// Every mutation emits a row into paliad.partner_unit_events in the same tx
|
|
// as the originating change so the global audit timeline (audit_service.go)
|
|
// can render the full history. The unit name is snapshotted into the event
|
|
// row so 'deleted' rows stay readable after the FK ON DELETE SET NULL fires.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/offices"
|
|
)
|
|
|
|
// PartnerUnitService reads and writes paliad.partner_units.
|
|
type PartnerUnitService struct {
|
|
db *sqlx.DB
|
|
users *UserService
|
|
}
|
|
|
|
// NewPartnerUnitService wires the service.
|
|
func NewPartnerUnitService(db *sqlx.DB, users *UserService) *PartnerUnitService {
|
|
return &PartnerUnitService{db: db, users: users}
|
|
}
|
|
|
|
// CreatePartnerUnitInput is the payload for Create.
|
|
type CreatePartnerUnitInput struct {
|
|
Name string `json:"name"`
|
|
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
|
Office string `json:"office"`
|
|
}
|
|
|
|
// UpdatePartnerUnitInput is the partial-update payload.
|
|
type UpdatePartnerUnitInput struct {
|
|
Name *string `json:"name,omitempty"`
|
|
LeadUserID *uuid.UUID `json:"lead_user_id,omitempty"`
|
|
Office *string `json:"office,omitempty"`
|
|
}
|
|
|
|
// List returns every PartnerUnit (readable by any authenticated user — see RLS).
|
|
func (s *PartnerUnitService) List(ctx context.Context) ([]models.PartnerUnit, error) {
|
|
rows := []models.PartnerUnit{}
|
|
err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
|
FROM paliad.partner_units
|
|
ORDER BY office, name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list partner_units: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// GetByID returns one PartnerUnit or (nil, sql.ErrNoRows).
|
|
func (s *PartnerUnitService) GetByID(ctx context.Context, id uuid.UUID) (*models.PartnerUnit, error) {
|
|
var d models.PartnerUnit
|
|
err := s.db.GetContext(ctx, &d,
|
|
`SELECT id, name, lead_user_id, office, created_at, updated_at
|
|
FROM paliad.partner_units WHERE id = $1`, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get partner_unit: %w", err)
|
|
}
|
|
return &d, nil
|
|
}
|
|
|
|
// Create inserts a PartnerUnit. Admin-only. Emits a 'created' audit event
|
|
// in the same tx.
|
|
func (s *PartnerUnitService) Create(ctx context.Context, callerID uuid.UUID, input CreatePartnerUnitInput) (*models.PartnerUnit, error) {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(input.Name) == "" {
|
|
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
|
}
|
|
if !offices.IsValid(input.Office) {
|
|
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, input.Office)
|
|
}
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.partner_units (id, name, lead_user_id, office, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $5)`,
|
|
id, input.Name, input.LeadUserID, input.Office, now); err != nil {
|
|
return nil, fmt.Errorf("insert partner_unit: %w", err)
|
|
}
|
|
|
|
if err := s.emit(ctx, tx, callerID, &id, input.Name, "created", map[string]any{
|
|
"name": input.Name,
|
|
"office": input.Office,
|
|
"lead_user_id": input.LeadUserID,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit tx: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// Update applies a partial update. Admin-only. Emits an 'updated' event with
|
|
// before/after snapshots in the same tx.
|
|
func (s *PartnerUnitService) Update(ctx context.Context, callerID, id uuid.UUID, input UpdatePartnerUnitInput) (*models.PartnerUnit, error) {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return nil, err
|
|
}
|
|
current, err := s.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []any{}
|
|
next := 1
|
|
appendSet := func(col string, val any) {
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
|
args = append(args, val)
|
|
next++
|
|
}
|
|
before := map[string]any{}
|
|
after := map[string]any{}
|
|
fields := []string{}
|
|
if input.Name != nil && *input.Name != current.Name {
|
|
appendSet("name", *input.Name)
|
|
before["name"] = current.Name
|
|
after["name"] = *input.Name
|
|
fields = append(fields, "name")
|
|
}
|
|
if input.LeadUserID != nil {
|
|
curLead := uuid.Nil
|
|
if current.LeadUserID != nil {
|
|
curLead = *current.LeadUserID
|
|
}
|
|
if *input.LeadUserID != curLead {
|
|
appendSet("lead_user_id", *input.LeadUserID)
|
|
before["lead_user_id"] = current.LeadUserID
|
|
after["lead_user_id"] = *input.LeadUserID
|
|
fields = append(fields, "lead_user_id")
|
|
}
|
|
}
|
|
if input.Office != nil && *input.Office != current.Office {
|
|
if !offices.IsValid(*input.Office) {
|
|
return nil, fmt.Errorf("%w: invalid office %q", ErrInvalidInput, *input.Office)
|
|
}
|
|
appendSet("office", *input.Office)
|
|
before["office"] = current.Office
|
|
after["office"] = *input.Office
|
|
fields = append(fields, "office")
|
|
}
|
|
if len(sets) == 0 {
|
|
return current, nil
|
|
}
|
|
appendSet("updated_at", time.Now().UTC())
|
|
args = append(args, id)
|
|
query := fmt.Sprintf("UPDATE paliad.partner_units SET %s WHERE id = $%d",
|
|
strings.Join(sets, ", "), next)
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
|
|
return nil, fmt.Errorf("update partner_unit: %w", err)
|
|
}
|
|
if err := s.emit(ctx, tx, callerID, &id, current.Name, "updated", map[string]any{
|
|
"before": before,
|
|
"after": after,
|
|
"fields": fields,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit tx: %w", err)
|
|
}
|
|
return s.GetByID(ctx, id)
|
|
}
|
|
|
|
// Delete removes a PartnerUnit (cascades memberships). Admin-only. Emits a
|
|
// 'deleted' audit event in the same tx — the FK on partner_unit_events has
|
|
// ON DELETE SET NULL so the historical row survives the cascade.
|
|
func (s *PartnerUnitService) Delete(ctx context.Context, callerID, id uuid.UUID) error {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
current, err := s.GetByID(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var memberCount int
|
|
if err := s.db.GetContext(ctx, &memberCount,
|
|
`SELECT COUNT(*) FROM paliad.partner_unit_members WHERE partner_unit_id = $1`, id); err != nil {
|
|
return fmt.Errorf("count members: %w", err)
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
// Emit BEFORE delete so the FK still resolves; ON DELETE SET NULL fires
|
|
// on cascade and clears partner_unit_id while keeping unit_name + payload.
|
|
if err := s.emit(ctx, tx, callerID, &id, current.Name, "deleted", map[string]any{
|
|
"name": current.Name,
|
|
"office": current.Office,
|
|
"lead_user_id": current.LeadUserID,
|
|
"member_count": memberCount,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM paliad.partner_units WHERE id = $1`, id); err != nil {
|
|
return fmt.Errorf("delete partner_unit: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit tx: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddMember inserts a (partner_unit, user) membership. Admin-only. Idempotent.
|
|
// Emits 'member_added' only when a row is actually inserted.
|
|
func (s *PartnerUnitService) AddMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
unit, err := s.GetByID(ctx, partnerUnitID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
|
|
VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
|
|
partnerUnitID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("add partner_unit member: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return tx.Commit()
|
|
}
|
|
|
|
var disp struct {
|
|
DN string `db:"display_name"`
|
|
Em string `db:"email"`
|
|
}
|
|
_ = s.db.GetContext(ctx, &disp,
|
|
`SELECT display_name, email FROM paliad.users WHERE id = $1`, userID)
|
|
|
|
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_added", map[string]any{
|
|
"user_id": userID,
|
|
"display_name": disp.DN,
|
|
"email": disp.Em,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// RemoveMember deletes a (partner_unit, user) membership. Admin-only.
|
|
// Emits 'member_removed' only when a row is actually deleted.
|
|
func (s *PartnerUnitService) RemoveMember(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID) error {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
unit, err := s.GetByID(ctx, partnerUnitID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
res, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.partner_unit_members WHERE partner_unit_id = $1 AND user_id = $2`,
|
|
partnerUnitID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("remove partner_unit member: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return tx.Commit()
|
|
}
|
|
|
|
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_removed", map[string]any{
|
|
"user_id": userID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// SetMemberRole updates the unit_role column on a (partner_unit, user)
|
|
// membership. Admin-only. Validates the role against the migration-055 CHECK.
|
|
// Emits 'member_role_changed' carrying the prior + new role values so the
|
|
// audit trail captures the transition.
|
|
func (s *PartnerUnitService) SetMemberRole(ctx context.Context, callerID, partnerUnitID, userID uuid.UUID, role string) error {
|
|
if err := s.requireAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
if !isValidUnitRole(role) {
|
|
return fmt.Errorf("%w: invalid unit_role %q", ErrInvalidInput, role)
|
|
}
|
|
unit, err := s.GetByID(ctx, partnerUnitID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
var prior string
|
|
err = tx.GetContext(ctx, &prior,
|
|
`SELECT unit_role FROM paliad.partner_unit_members
|
|
WHERE partner_unit_id = $1 AND user_id = $2`,
|
|
partnerUnitID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("read prior unit_role: %w", err)
|
|
}
|
|
if prior == role {
|
|
return tx.Commit()
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.partner_unit_members
|
|
SET unit_role = $3
|
|
WHERE partner_unit_id = $1 AND user_id = $2`,
|
|
partnerUnitID, userID, role); err != nil {
|
|
return fmt.Errorf("update unit_role: %w", err)
|
|
}
|
|
|
|
if err := s.emit(ctx, tx, callerID, &partnerUnitID, unit.Name, "member_role_changed", map[string]any{
|
|
"user_id": userID,
|
|
"old_role": prior,
|
|
"new_role": role,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// AddMemberTx is the same as AddMember but runs inside the caller's tx and
|
|
// skips the admin gate (caller has already authorised the parent operation).
|
|
// Used by user_service.OnboardUser to insert a partner_unit membership in
|
|
// the same tx as the user-create.
|
|
func (s *PartnerUnitService) AddMemberTx(ctx context.Context, tx *sqlx.Tx, actorID, partnerUnitID, userID uuid.UUID) error {
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.partner_unit_members (partner_unit_id, user_id, created_at)
|
|
VALUES ($1, $2, now()) ON CONFLICT (partner_unit_id, user_id) DO NOTHING`,
|
|
partnerUnitID, userID)
|
|
if err != nil {
|
|
return fmt.Errorf("add partner_unit member (tx): %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return nil
|
|
}
|
|
var unitName string
|
|
if err := tx.GetContext(ctx, &unitName,
|
|
`SELECT name FROM paliad.partner_units WHERE id = $1`, partnerUnitID); err != nil {
|
|
return fmt.Errorf("lookup partner_unit name: %w", err)
|
|
}
|
|
var disp struct {
|
|
DN string `db:"display_name"`
|
|
Em string `db:"email"`
|
|
}
|
|
_ = tx.GetContext(ctx, &disp,
|
|
`SELECT display_name, email FROM paliad.users WHERE id = $1`, userID)
|
|
|
|
return s.emit(ctx, tx, actorID, &partnerUnitID, unitName, "member_added", map[string]any{
|
|
"user_id": userID,
|
|
"display_name": disp.DN,
|
|
"email": disp.Em,
|
|
"source": "onboarding",
|
|
})
|
|
}
|
|
|
|
// PartnerUnitMemberDetail is one user's membership row enriched with display
|
|
// fields for the admin/team UIs.
|
|
//
|
|
// UnitRole (added by t-paliad-139 / migration 055) is the per-unit role
|
|
// distinction used by the derivation rule: a unit attached to a project
|
|
// auto-derives its members whose unit_role is in the attachment's
|
|
// derive_unit_roles set (default {pa, senior_pa}). Possible values:
|
|
// 'lead' | 'attorney' | 'senior_pa' | 'pa' | 'paralegal'. Defaults to
|
|
// 'attorney' for every pre-055 row.
|
|
type PartnerUnitMemberDetail struct {
|
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
|
Email string `db:"email" json:"email"`
|
|
DisplayName string `db:"display_name" json:"display_name"`
|
|
Office string `db:"office" json:"office"`
|
|
JobTitle *string `db:"job_title" json:"job_title"`
|
|
UnitRole string `db:"unit_role" json:"unit_role"`
|
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
|
}
|
|
|
|
// PartnerUnitMemberRole values (mirror migration 055 CHECK constraint).
|
|
const (
|
|
UnitRoleLead = "lead"
|
|
UnitRoleAttorney = "attorney"
|
|
UnitRoleSeniorPA = "senior_pa"
|
|
UnitRolePA = "pa"
|
|
UnitRoleParalegal = "paralegal"
|
|
)
|
|
|
|
func isValidUnitRole(r string) bool {
|
|
switch r {
|
|
case UnitRoleLead, UnitRoleAttorney, UnitRoleSeniorPA, UnitRolePA, UnitRoleParalegal:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ListMembers returns users in the PartnerUnit, enriched with display fields.
|
|
//
|
|
// INNER JOIN on paliad.users: partner_unit_members.user_id FKs auth.users, so
|
|
// pre-onboarding members (auth row exists, paliad.users row doesn't) would
|
|
// otherwise produce NULL display_name/office and break the scan.
|
|
// Skipping them is the right UX — without an onboarded profile there's
|
|
// nothing meaningful to render.
|
|
func (s *PartnerUnitService) ListMembers(ctx context.Context, partnerUnitID uuid.UUID) ([]PartnerUnitMemberDetail, error) {
|
|
var rows []PartnerUnitMemberDetail
|
|
err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT pum.user_id, pum.created_at, pum.unit_role,
|
|
u.email, u.display_name, u.office, u.job_title
|
|
FROM paliad.partner_unit_members pum
|
|
JOIN paliad.users u ON u.id = pum.user_id
|
|
WHERE pum.partner_unit_id = $1
|
|
ORDER BY u.display_name`, partnerUnitID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list partner_unit members: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// PartnerUnitWithMembers is a unit row enriched with its lead user
|
|
// snapshot and full member list. Used by the /team directory page so the
|
|
// frontend can render the "by partner unit" grouping with one fetch.
|
|
type PartnerUnitWithMembers struct {
|
|
models.PartnerUnit
|
|
LeadDisplayName *string `json:"lead_display_name,omitempty"`
|
|
LeadEmail *string `json:"lead_email,omitempty"`
|
|
Members []PartnerUnitMemberDetail `json:"members"`
|
|
}
|
|
|
|
// ListWithMembers returns every PartnerUnit enriched with its lead's display
|
|
// name + email and the full members list. Two short queries (one per
|
|
// table) are joined in Go to avoid a Cartesian explosion when units have
|
|
// many members.
|
|
func (s *PartnerUnitService) ListWithMembers(ctx context.Context) ([]PartnerUnitWithMembers, error) {
|
|
type unitRow struct {
|
|
models.PartnerUnit
|
|
LeadDisplayName *string `db:"lead_display_name"`
|
|
LeadEmail *string `db:"lead_email"`
|
|
}
|
|
var units []unitRow
|
|
err := s.db.SelectContext(ctx, &units,
|
|
`SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at,
|
|
lu.display_name AS lead_display_name,
|
|
lu.email AS lead_email
|
|
FROM paliad.partner_units pu
|
|
LEFT JOIN paliad.users lu ON lu.id = pu.lead_user_id
|
|
ORDER BY pu.office, pu.name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list partner_units: %w", err)
|
|
}
|
|
|
|
type memberRow struct {
|
|
PartnerUnitMemberDetail
|
|
PartnerUnitID uuid.UUID `db:"partner_unit_id"`
|
|
}
|
|
var members []memberRow
|
|
err = s.db.SelectContext(ctx, &members,
|
|
`SELECT pum.partner_unit_id, pum.user_id, pum.created_at, pum.unit_role,
|
|
u.email, u.display_name, u.office, u.job_title
|
|
FROM paliad.partner_unit_members pum
|
|
JOIN paliad.users u ON u.id = pum.user_id
|
|
ORDER BY u.display_name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list partner_unit members: %w", err)
|
|
}
|
|
|
|
byUnit := map[uuid.UUID][]PartnerUnitMemberDetail{}
|
|
for _, m := range members {
|
|
byUnit[m.PartnerUnitID] = append(byUnit[m.PartnerUnitID], m.PartnerUnitMemberDetail)
|
|
}
|
|
|
|
out := make([]PartnerUnitWithMembers, len(units))
|
|
for i, u := range units {
|
|
out[i] = PartnerUnitWithMembers{
|
|
PartnerUnit: u.PartnerUnit,
|
|
LeadDisplayName: u.LeadDisplayName,
|
|
LeadEmail: u.LeadEmail,
|
|
Members: byUnit[u.ID],
|
|
}
|
|
if out[i].Members == nil {
|
|
out[i].Members = []PartnerUnitMemberDetail{}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GetMembership returns the user's PartnerUnit memberships (zero or more).
|
|
// Used by the settings page to render the user's own partner unit card.
|
|
func (s *PartnerUnitService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.PartnerUnit, error) {
|
|
rows := []models.PartnerUnit{}
|
|
err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT pu.id, pu.name, pu.lead_user_id, pu.office, pu.created_at, pu.updated_at
|
|
FROM paliad.partner_units pu
|
|
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = pu.id
|
|
WHERE pum.user_id = $1
|
|
ORDER BY pu.name`, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get user partner_unit memberships: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (s *PartnerUnitService) requireAdmin(ctx context.Context, userID uuid.UUID) error {
|
|
u, err := s.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u == nil || u.GlobalRole != "global_admin" {
|
|
return fmt.Errorf("%w: global admin required", ErrForbidden)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// emit writes one audit row to paliad.partner_unit_events inside the caller's
|
|
// tx. unitName is snapshotted so deleted units stay readable in the timeline
|
|
// (their FK is cleared to NULL by ON DELETE SET NULL after the unit row is
|
|
// gone).
|
|
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx, actorID uuid.UUID,
|
|
unitID *uuid.UUID, unitName, eventType string, payload any) error {
|
|
p, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal audit payload: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.partner_unit_events
|
|
(partner_unit_id, actor_id, event_type, unit_name, payload)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
unitID, actorID, eventType, unitName, p); err != nil {
|
|
return fmt.Errorf("emit partner_unit event %q: %w", eventType, err)
|
|
}
|
|
return nil
|
|
}
|