Files
paliad/internal/services/partner_unit_service.go
m 76785da3f6 feat(t-paliad-070): rename Department → PartnerUnit on the Go side
Backend rename (frontend lands in next commit):
- Migration 026: rename paliad.departments → paliad.partner_units,
  paliad.department_members → paliad.partner_unit_members, junction FK
  department_id → partner_unit_id, plus all constraints/indexes/policies.
  Pre-drop seed re-runs migration 019's logic to capture any users.dezernat
  drift, then DROP COLUMN. Adds paliad.partner_unit_events audit table
  with RLS (any-authenticated read, global_admin write).
- models.User.Dezernat dropped. Department / DepartmentMember →
  PartnerUnit / PartnerUnitMember.
- DepartmentService → PartnerUnitService (file renamed via git mv to
  preserve blame). Every mutation now opens a tx and emits a
  partner_unit_events row in the same tx (created/updated/deleted/
  member_added/member_removed). Update emits before/after snapshots;
  Delete emits BEFORE the cascade so the FK still resolves, then
  ON DELETE SET NULL keeps the historical row.
- /api/departments/* → /api/partner-units/*. Handlers renamed.
- New /admin/partner-units page handler stub.
- AuditService UNIONs the new partner_unit_events source as a 4th
  branch; handler accepts AuditSourcePartnerUnitEvents.
- user_service: drop dezernat from CreateUserInput / UpdateProfileInput
  / AdminCreateInput / AdminUpdateInput. CreateUserInput gains
  PartnerUnitID *uuid.UUID — onboarding can pick an initial unit and
  the membership row + audit event are inserted in the same tx.
- Settings tab aliases drop dezernat/department.
- Legacy /dezernate and /departments now redirect to
  /admin/partner-units (admins only see it; non-admins land on the
  forbidden bounce).

go build / vet / test compile clean.
2026-04-29 22:03:08 +02:00

511 lines
17 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/patholo/internal/models"
"mgit.msbls.de/m/patholo/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()
}
// 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.
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"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// 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,
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,
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
}