F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed m/patholo → mAi/paliad → m/paliad, but go.mod still declared `mgit.msbls.de/m/patholo` and every internal import echoed the pre-rebrand name. Sweep: - go.mod: module path → mgit.msbls.de/m/paliad - All *.go files: imports rewritten via sed - README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad - Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx, global.css Verified: go build/vet/test ./... clean, bun run build clean, no remaining mgit.msbls.de/m/patholo or mAi/paliad references outside docs that intentionally describe the rename history.
511 lines
17 KiB
Go
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/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()
|
|
}
|
|
|
|
// 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
|
|
}
|