m/paliad#61 Slice B backend. Implements the explicit-share path
(checklist_shares + visibility predicate extension) and the
global_admin-only promotion / demotion of authored templates to and
from the firm catalog.
Schema (mig 115, idempotent):
- paliad.checklist_shares (uuid id, checklist_id FK, polymorphic
recipient via xor-check: recipient_kind in {user, office,
partner_unit, project} with exactly one matching recipient_* column
populated; granted_by FK; granted_at)
- Hot-path lookup index + per-kind partial UNIQUE indexes prevent
duplicate grants
- RLS: SELECT owner OR self-recipient (user-kind) OR global_admin;
INSERT owner-only with granted_by=self; DELETE owner OR global_admin;
no UPDATE (revoke = DELETE)
- can_see_checklist CREATE OR REPLACE — adds 4 share branches; project-
share branch uses inline ltree walk over projects.path because
can_see_project reads auth.uid() (NULL on service-role connection,
same pattern as visibility.go)
- xor-check verified live: rejects kind='user' with recipient_office
set; accepts the matching kind/recipient pair
Services:
- ChecklistShareService — Grant (owner-only, validates recipient kind +
required FK target, friendly 409 on partial-unique-index conflict),
Revoke (owner or global_admin), ListGrants (owner or global_admin;
enriches recipient_label via LEFT JOINs)
- ChecklistPromotionService — Promote (global_admin → visibility=global
+ promoted_at/by + audit), Demote (global_admin → target visibility,
default 'firm', clears promoted_at/by; rejects demote of non-global
rows)
- ChecklistCatalogService.checklistVisibilityPredicate extended to
include all 5 share branches; service-role-friendly (no auth.uid())
- ChecklistTemplateService.normaliseSliceAVisibility now accepts
'shared' as an author-set value; 'global' stays admin-only
Endpoints:
- GET /api/checklists/templates/{slug}/shares — list grants (owner/admin)
- POST /api/checklists/templates/{slug}/shares — grant
- DELETE /api/checklists/shares/{id} — revoke
- POST /api/admin/checklists/{slug}/promote — promote to global
- POST /api/admin/checklists/{slug}/demote — demote (body.target default 'firm')
Audit (paliad.system_audit_log):
- checklist.shared — recipient_kind + recipient_id in metadata
- checklist.unshared — same shape, captured pre-DELETE
- checklist.promoted_global — prior_visibility + owner_id
- checklist.demoted — target_visibility
Tests: validateShareInput covers all 4 kinds (happy + missing-id);
predicate-shape test asserts all 6 visibility branches present;
pqUniqueViolation regex sniff; nullableString helper; SliceB visibility
opens 'shared' but keeps 'global' admin-only.
Hotfix-merge note: head shipped 794617c after Slice A — the
template-edit page route moved from /checklists/{slug}/edit to
/checklists/templates/{slug}/edit to disambiguate from
/checklists/instances/{id}. Slice B routes follow the safe
/<resource>/<noun>/{id} pattern (no new {slug}-then-verb endpoints).
154 lines
4.6 KiB
Go
154 lines
4.6 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
// ChecklistPromotionService implements the global_admin-only promote /
|
|
// demote flow for paliad.checklists. Promote flips visibility to
|
|
// 'global' and stamps promoted_at / promoted_by; demote flips it back
|
|
// to a caller-chosen target ('firm' default — preserves visibility for
|
|
// already-instantiated users).
|
|
type ChecklistPromotionService struct {
|
|
db *sqlx.DB
|
|
templates *ChecklistTemplateService
|
|
audit *SystemAuditLogService
|
|
users *UserService
|
|
}
|
|
|
|
func NewChecklistPromotionService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistPromotionService {
|
|
return &ChecklistPromotionService{db: db, templates: templates, audit: audit, users: users}
|
|
}
|
|
|
|
// validDemoteTargets — narrowing the visibility back from 'global' is
|
|
// only allowed to a state where the row is still meaningful. 'global'
|
|
// would be a no-op; 'shared' would orphan existing instance owners who
|
|
// already see it without a grant. Default is 'firm'.
|
|
var validDemoteTargets = map[string]bool{"firm": true, "private": true}
|
|
|
|
// Promote flips an authored template to visibility='global'. Caller
|
|
// must be global_admin. Emits 'checklist.promoted_global' audit event
|
|
// with the prior visibility captured for the demote-undo path.
|
|
func (s *ChecklistPromotionService) Promote(ctx context.Context, callerID uuid.UUID, slug string) error {
|
|
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if row.Visibility == "global" {
|
|
return nil
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin promote tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.checklists
|
|
SET visibility = 'global',
|
|
promoted_at = $2,
|
|
promoted_by = $3,
|
|
updated_at = $2
|
|
WHERE id = $1`, row.ID, time.Now().UTC(), callerID); err != nil {
|
|
return fmt.Errorf("promote checklist: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, callerID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.promoted_global",
|
|
ActorID: callerID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"owner_id": row.OwnerID,
|
|
"prior_visibility": row.Visibility,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Demote narrows visibility from 'global' to target. target defaults to
|
|
// 'firm' when empty. promoted_at / promoted_by are cleared.
|
|
func (s *ChecklistPromotionService) Demote(ctx context.Context, callerID uuid.UUID, slug, target string) error {
|
|
if err := s.requireGlobalAdmin(ctx, callerID); err != nil {
|
|
return err
|
|
}
|
|
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t := strings.ToLower(strings.TrimSpace(target))
|
|
if t == "" {
|
|
t = "firm"
|
|
}
|
|
if !validDemoteTargets[t] {
|
|
return fmt.Errorf("%w: demote target must be firm | private, got %q", ErrInvalidInput, target)
|
|
}
|
|
if row.Visibility != "global" {
|
|
return fmt.Errorf("%w: checklist is not currently promoted (visibility=%s)", ErrInvalidInput, row.Visibility)
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin demote tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.checklists
|
|
SET visibility = $2,
|
|
promoted_at = NULL,
|
|
promoted_by = NULL,
|
|
updated_at = now()
|
|
WHERE id = $1`, row.ID, t); err != nil {
|
|
return fmt.Errorf("demote checklist: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, callerID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.demoted",
|
|
ActorID: callerID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"target_visibility": t,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *ChecklistPromotionService) requireGlobalAdmin(ctx context.Context, callerID uuid.UUID) error {
|
|
user, err := s.users.GetByID(ctx, callerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil || user.GlobalRole != "global_admin" {
|
|
return fmt.Errorf("%w: only global_admin can promote / demote checklists", ErrForbidden)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ChecklistPromotionService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
|
|
u, err := s.users.GetByID(ctx, userID)
|
|
if err != nil || u == nil {
|
|
return "", err
|
|
}
|
|
return u.Email, nil
|
|
}
|