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 }