Files
paliad/internal/services/partner_unit_service.go
m 544bb63684 feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
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.
2026-05-06 16:41:41 +02:00

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
}