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).
332 lines
12 KiB
Go
332 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/offices"
|
|
)
|
|
|
|
// ChecklistShareService is the write surface for paliad.checklist_shares
|
|
// (mig 115). Owners grant; owner-or-global_admin revokes. ListGrants is
|
|
// owner-only (returning all 4 recipient kinds) — recipients see "this
|
|
// is shared with me" only implicitly via the visibility predicate.
|
|
type ChecklistShareService struct {
|
|
db *sqlx.DB
|
|
templates *ChecklistTemplateService
|
|
audit *SystemAuditLogService
|
|
users *UserService
|
|
}
|
|
|
|
func NewChecklistShareService(db *sqlx.DB, templates *ChecklistTemplateService, audit *SystemAuditLogService, users *UserService) *ChecklistShareService {
|
|
return &ChecklistShareService{db: db, templates: templates, audit: audit, users: users}
|
|
}
|
|
|
|
// ShareGrantInput is the POST body for granting a share. Exactly one
|
|
// of the recipient_* fields must be set, matching recipient_kind.
|
|
type ShareGrantInput struct {
|
|
RecipientKind string `json:"recipient_kind"`
|
|
UserID *uuid.UUID `json:"recipient_user_id,omitempty"`
|
|
Office string `json:"recipient_office,omitempty"`
|
|
PartnerUnitID *uuid.UUID `json:"recipient_partner_unit_id,omitempty"`
|
|
ProjectID *uuid.UUID `json:"recipient_project_id,omitempty"`
|
|
}
|
|
|
|
// Share is the row shape returned from list / grant calls.
|
|
type Share struct {
|
|
ID uuid.UUID `db:"id" json:"id"`
|
|
ChecklistID uuid.UUID `db:"checklist_id" json:"checklist_id"`
|
|
RecipientKind string `db:"recipient_kind" json:"recipient_kind"`
|
|
RecipientUserID *uuid.UUID `db:"recipient_user_id" json:"recipient_user_id,omitempty"`
|
|
RecipientOffice *string `db:"recipient_office" json:"recipient_office,omitempty"`
|
|
RecipientPartnerUnitID *uuid.UUID `db:"recipient_partner_unit_id" json:"recipient_partner_unit_id,omitempty"`
|
|
RecipientProjectID *uuid.UUID `db:"recipient_project_id" json:"recipient_project_id,omitempty"`
|
|
GrantedBy uuid.UUID `db:"granted_by" json:"granted_by"`
|
|
GrantedAt time.Time `db:"granted_at" json:"granted_at"`
|
|
// Display-name enrichment for the recipient — owners want to see
|
|
// "Sarah Schmidt" not just a UUID on the grants list.
|
|
RecipientLabel string `db:"recipient_label" json:"recipient_label"`
|
|
}
|
|
|
|
// Grant creates a new share row. Caller must own the parent checklist
|
|
// (or be global_admin). Recipient validity (FK targets exist + kind
|
|
// matches the populated recipient_* column) enforced before INSERT.
|
|
func (s *ChecklistShareService) Grant(ctx context.Context, callerID uuid.UUID, slug string, input ShareGrantInput) (*Share, error) {
|
|
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Ownership check — Grant is owner-only (global_admin can demote
|
|
// global templates but doesn't author shares).
|
|
if row.OwnerID != callerID {
|
|
return nil, fmt.Errorf("%w: only the owner can grant shares", ErrForbidden)
|
|
}
|
|
|
|
kind := strings.ToLower(strings.TrimSpace(input.RecipientKind))
|
|
if err := validateShareInput(kind, input); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
id := uuid.New()
|
|
now := time.Now().UTC()
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin grant tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.checklist_shares
|
|
(id, checklist_id, recipient_kind, recipient_user_id, recipient_office,
|
|
recipient_partner_unit_id, recipient_project_id, granted_by, granted_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
id, row.ID, kind,
|
|
input.UserID, nullableString(input.Office), input.PartnerUnitID, input.ProjectID,
|
|
callerID, now,
|
|
); err != nil {
|
|
// Map the partial-unique-index conflict into a friendly 409.
|
|
if pqUniqueViolation(err) {
|
|
return nil, fmt.Errorf("%w: this recipient already has a grant on this checklist", ErrInvalidInput)
|
|
}
|
|
return nil, fmt.Errorf("insert checklist_share: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, callerID)
|
|
meta := map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"share_id": id,
|
|
"recipient_kind": kind,
|
|
}
|
|
switch kind {
|
|
case "user":
|
|
meta["recipient_user_id"] = input.UserID
|
|
case "office":
|
|
meta["recipient_office"] = input.Office
|
|
case "partner_unit":
|
|
meta["recipient_partner_unit_id"] = input.PartnerUnitID
|
|
case "project":
|
|
meta["recipient_project_id"] = input.ProjectID
|
|
}
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.shared",
|
|
ActorID: callerID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: meta,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit grant: %w", err)
|
|
}
|
|
return s.getShareByID(ctx, callerID, id)
|
|
}
|
|
|
|
// Revoke deletes a share row. Owner of the parent checklist OR
|
|
// global_admin. Audited as 'checklist.unshared' with the recipient meta
|
|
// captured pre-delete.
|
|
func (s *ChecklistShareService) Revoke(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) error {
|
|
share, err := s.getShareByID(ctx, callerID, shareID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Resolve owner of the parent checklist for the authorization gate.
|
|
// templates.GetBySlug needs a slug we don't have; inline a minimal
|
|
// owner lookup keyed on the share's checklist_id.
|
|
var ownerID uuid.UUID
|
|
if err := s.db.GetContext(ctx, &ownerID,
|
|
`SELECT owner_id FROM paliad.checklists WHERE id = $1`, share.ChecklistID); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotVisible
|
|
}
|
|
return fmt.Errorf("fetch checklist owner: %w", err)
|
|
}
|
|
if ownerID != callerID {
|
|
user, err := s.users.GetByID(ctx, callerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if user == nil || user.GlobalRole != "global_admin" {
|
|
return fmt.Errorf("%w: only the owner or a global_admin can revoke a share", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin revoke tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.checklist_shares WHERE id = $1`, shareID); err != nil {
|
|
return fmt.Errorf("delete checklist_share: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, callerID)
|
|
meta := map[string]any{
|
|
"checklist_id": share.ChecklistID,
|
|
"share_id": share.ID,
|
|
"recipient_kind": share.RecipientKind,
|
|
}
|
|
switch share.RecipientKind {
|
|
case "user":
|
|
meta["recipient_user_id"] = share.RecipientUserID
|
|
case "office":
|
|
meta["recipient_office"] = share.RecipientOffice
|
|
case "partner_unit":
|
|
meta["recipient_partner_unit_id"] = share.RecipientPartnerUnitID
|
|
case "project":
|
|
meta["recipient_project_id"] = share.RecipientProjectID
|
|
}
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.unshared",
|
|
ActorID: callerID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: meta,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListGrants returns every share row for the checklist. Owner-only —
|
|
// recipients only learn about shares affecting them implicitly via the
|
|
// visibility predicate.
|
|
func (s *ChecklistShareService) ListGrants(ctx context.Context, callerID uuid.UUID, slug string) ([]Share, error) {
|
|
row, err := s.templates.GetBySlug(ctx, callerID, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.OwnerID != callerID {
|
|
user, err := s.users.GetByID(ctx, callerID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil || user.GlobalRole != "global_admin" {
|
|
return nil, fmt.Errorf("%w: only the owner or a global_admin can list shares", ErrForbidden)
|
|
}
|
|
}
|
|
|
|
rows := []Share{}
|
|
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
|
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
|
s.granted_by, s.granted_at,
|
|
COALESCE(
|
|
CASE s.recipient_kind
|
|
WHEN 'user' THEN ru.display_name
|
|
WHEN 'office' THEN s.recipient_office
|
|
WHEN 'partner_unit' THEN pu.name
|
|
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
|
END,
|
|
''
|
|
) AS recipient_label
|
|
FROM paliad.checklist_shares s
|
|
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
|
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
|
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
|
WHERE s.checklist_id = $1
|
|
ORDER BY s.granted_at DESC`
|
|
if err := s.db.SelectContext(ctx, &rows, q, row.ID); err != nil {
|
|
return nil, fmt.Errorf("list checklist_shares: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// --- internals ------------------------------------------------------------
|
|
|
|
func (s *ChecklistShareService) getShareByID(ctx context.Context, callerID uuid.UUID, shareID uuid.UUID) (*Share, error) {
|
|
var row Share
|
|
q := `SELECT s.id, s.checklist_id, s.recipient_kind, s.recipient_user_id,
|
|
s.recipient_office, s.recipient_partner_unit_id, s.recipient_project_id,
|
|
s.granted_by, s.granted_at,
|
|
COALESCE(
|
|
CASE s.recipient_kind
|
|
WHEN 'user' THEN ru.display_name
|
|
WHEN 'office' THEN s.recipient_office
|
|
WHEN 'partner_unit' THEN pu.name
|
|
WHEN 'project' THEN COALESCE(pr.reference, pr.title)
|
|
END,
|
|
''
|
|
) AS recipient_label
|
|
FROM paliad.checklist_shares s
|
|
LEFT JOIN paliad.users ru ON ru.id = s.recipient_user_id
|
|
LEFT JOIN paliad.partner_units pu ON pu.id = s.recipient_partner_unit_id
|
|
LEFT JOIN paliad.projects pr ON pr.id = s.recipient_project_id
|
|
WHERE s.id = $1`
|
|
err := s.db.GetContext(ctx, &row, q, shareID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch checklist_share: %w", err)
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
func (s *ChecklistShareService) 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
|
|
}
|
|
|
|
// --- pure helpers ---------------------------------------------------------
|
|
|
|
func validateShareInput(kind string, input ShareGrantInput) error {
|
|
switch kind {
|
|
case "user":
|
|
if input.UserID == nil {
|
|
return fmt.Errorf("%w: recipient_user_id required when recipient_kind=user", ErrInvalidInput)
|
|
}
|
|
case "office":
|
|
off := strings.TrimSpace(input.Office)
|
|
if off == "" {
|
|
return fmt.Errorf("%w: recipient_office required when recipient_kind=office", ErrInvalidInput)
|
|
}
|
|
if !offices.IsValid(off) {
|
|
return fmt.Errorf("%w: unknown office %q", ErrInvalidInput, off)
|
|
}
|
|
case "partner_unit":
|
|
if input.PartnerUnitID == nil {
|
|
return fmt.Errorf("%w: recipient_partner_unit_id required when recipient_kind=partner_unit", ErrInvalidInput)
|
|
}
|
|
case "project":
|
|
if input.ProjectID == nil {
|
|
return fmt.Errorf("%w: recipient_project_id required when recipient_kind=project", ErrInvalidInput)
|
|
}
|
|
default:
|
|
return fmt.Errorf("%w: recipient_kind must be user|office|partner_unit|project, got %q", ErrInvalidInput, kind)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func nullableString(s string) any {
|
|
t := strings.TrimSpace(s)
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
return t
|
|
}
|
|
|
|
// pqUniqueViolation reports whether the error is a Postgres
|
|
// unique_violation (SQLSTATE 23505). lib/pq exposes it via the .Code()
|
|
// method; sqlx surfaces it untouched. We sniff via the error string to
|
|
// avoid pulling in lib/pq's Error type here.
|
|
func pqUniqueViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "23505") || strings.Contains(msg, "duplicate key")
|
|
}
|