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).
551 lines
16 KiB
Go
551 lines
16 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/checklists"
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// ChecklistTemplateService is the write surface for user-authored checklist
|
|
// templates (paliad.checklists, mig 114). Create / Update / Delete on
|
|
// owner-only paths; SetVisibility on private↔firm only (Slice A — Slice B
|
|
// adds 'shared' grants, Slice C adds 'global' via admin promotion).
|
|
type ChecklistTemplateService struct {
|
|
db *sqlx.DB
|
|
catalog *ChecklistCatalogService
|
|
audit *SystemAuditLogService
|
|
users *UserService
|
|
}
|
|
|
|
func NewChecklistTemplateService(db *sqlx.DB, catalog *ChecklistCatalogService, audit *SystemAuditLogService, users *UserService) *ChecklistTemplateService {
|
|
return &ChecklistTemplateService{db: db, catalog: catalog, audit: audit, users: users}
|
|
}
|
|
|
|
// CreateTemplateInput is the POST body for authoring a new template.
|
|
//
|
|
// Body carries the groups[] / items[] sub-tree as JSONB; the surrounding
|
|
// metadata (title, regime, etc.) lives on dedicated columns. The
|
|
// handler validates the body shape upstream.
|
|
type CreateTemplateInput struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Regime string `json:"regime"`
|
|
Court string `json:"court"`
|
|
Reference string `json:"reference"`
|
|
Deadline string `json:"deadline"`
|
|
Lang string `json:"lang"`
|
|
Body json.RawMessage `json:"body"`
|
|
Visibility string `json:"visibility"`
|
|
}
|
|
|
|
// UpdateTemplateInput patches the owner-editable fields. Any field left
|
|
// nil is unchanged.
|
|
type UpdateTemplateInput struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
Regime *string `json:"regime,omitempty"`
|
|
Court *string `json:"court,omitempty"`
|
|
Reference *string `json:"reference,omitempty"`
|
|
Deadline *string `json:"deadline,omitempty"`
|
|
Body *json.RawMessage `json:"body,omitempty"`
|
|
}
|
|
|
|
var (
|
|
validRegimes = map[string]bool{"UPC": true, "DE": true, "EPA": true, "OTHER": true}
|
|
validLangs = map[string]bool{"de": true, "en": true}
|
|
// Author-settable visibilities. 'shared' is implicit (set
|
|
// automatically when the first checklist_shares row exists); 'global'
|
|
// is admin-only via ChecklistPromotionService.
|
|
validVisibilities = map[string]bool{"private": true, "firm": true, "shared": true}
|
|
titleMaxLen = 200
|
|
descriptionMaxLen = 2000
|
|
freeTextMaxLen = 200
|
|
slugSafeChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
|
)
|
|
|
|
// Create inserts a new authored template owned by userID. Returns the
|
|
// created row; emits a `checklist.authored` audit event.
|
|
func (s *ChecklistTemplateService) Create(ctx context.Context, userID uuid.UUID, input CreateTemplateInput) (*models.Checklist, error) {
|
|
title, err := requireNonEmptyTrimmed(input.Title, "title", titleMaxLen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
regime, err := normaliseRegime(input.Regime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lang, err := normaliseLang(input.Lang)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
visibility, err := normaliseSliceAVisibility(input.Visibility)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateBodyShape(input.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
slug, err := s.generateSlug(ctx, title)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
id := uuid.New()
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin create tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO paliad.checklists
|
|
(id, slug, owner_id, title, description, regime, court, reference,
|
|
deadline, lang, body, visibility, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13, $13)`,
|
|
id, slug, userID, title,
|
|
clampFreeText(input.Description, descriptionMaxLen),
|
|
regime,
|
|
clampFreeText(input.Court, freeTextMaxLen),
|
|
clampFreeText(input.Reference, freeTextMaxLen),
|
|
clampFreeText(input.Deadline, freeTextMaxLen),
|
|
lang,
|
|
string(input.Body),
|
|
visibility,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert checklist: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, userID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.authored",
|
|
ActorID: userID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": id,
|
|
"slug": slug,
|
|
"visibility": visibility,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit create checklist: %w", err)
|
|
}
|
|
return s.GetBySlug(ctx, userID, slug)
|
|
}
|
|
|
|
// Update mutates an authored template. Owner-only; non-owner attempts
|
|
// return ErrForbidden. Emits `checklist.edited` with the names of the
|
|
// changed fields in metadata.changed_fields[].
|
|
func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID, slug string, input UpdateTemplateInput) (*models.Checklist, error) {
|
|
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sets := []string{}
|
|
args := []any{}
|
|
next := 1
|
|
changed := []string{}
|
|
appendSet := func(col string, val any) {
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
|
args = append(args, val)
|
|
next++
|
|
}
|
|
|
|
if input.Title != nil {
|
|
t, err := requireNonEmptyTrimmed(*input.Title, "title", titleMaxLen)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
appendSet("title", t)
|
|
changed = append(changed, "title")
|
|
}
|
|
if input.Description != nil {
|
|
appendSet("description", clampFreeText(*input.Description, descriptionMaxLen))
|
|
changed = append(changed, "description")
|
|
}
|
|
if input.Regime != nil {
|
|
r, err := normaliseRegime(*input.Regime)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
appendSet("regime", r)
|
|
changed = append(changed, "regime")
|
|
}
|
|
if input.Court != nil {
|
|
appendSet("court", clampFreeText(*input.Court, freeTextMaxLen))
|
|
changed = append(changed, "court")
|
|
}
|
|
if input.Reference != nil {
|
|
appendSet("reference", clampFreeText(*input.Reference, freeTextMaxLen))
|
|
changed = append(changed, "reference")
|
|
}
|
|
if input.Deadline != nil {
|
|
appendSet("deadline", clampFreeText(*input.Deadline, freeTextMaxLen))
|
|
changed = append(changed, "deadline")
|
|
}
|
|
if input.Body != nil {
|
|
if err := validateBodyShape(*input.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
sets = append(sets, fmt.Sprintf("body = $%d::jsonb", next))
|
|
args = append(args, string(*input.Body))
|
|
next++
|
|
changed = append(changed, "body")
|
|
}
|
|
|
|
if len(sets) == 0 {
|
|
return row, nil
|
|
}
|
|
appendSet("updated_at", time.Now().UTC())
|
|
args = append(args, row.ID)
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin update tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
q := fmt.Sprintf(`UPDATE paliad.checklists SET %s WHERE id = $%d`,
|
|
strings.Join(sets, ", "), next)
|
|
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
|
|
return nil, fmt.Errorf("update checklist: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, userID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.edited",
|
|
ActorID: userID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"changed_fields": changed,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit update checklist: %w", err)
|
|
}
|
|
return s.GetBySlug(ctx, userID, slug)
|
|
}
|
|
|
|
// SetVisibility flips the visibility level. Slice A allows only the
|
|
// private ↔ firm transitions; Slice B opens 'shared' (requires share
|
|
// grants); Slice C opens 'global' via the admin promotion service.
|
|
func (s *ChecklistTemplateService) SetVisibility(ctx context.Context, userID uuid.UUID, slug string, visibility string) (*models.Checklist, error) {
|
|
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target, err := normaliseSliceAVisibility(visibility)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.Visibility == target {
|
|
return row, nil
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin visibility tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE paliad.checklists
|
|
SET visibility = $2, updated_at = now()
|
|
WHERE id = $1`, row.ID, target); err != nil {
|
|
return nil, fmt.Errorf("update visibility: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, userID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.visibility_changed",
|
|
ActorID: userID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"from": row.Visibility,
|
|
"to": target,
|
|
},
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit visibility: %w", err)
|
|
}
|
|
return s.GetBySlug(ctx, userID, slug)
|
|
}
|
|
|
|
// Delete removes the authored template. Existing instances survive via
|
|
// template_snapshot; new instance creation against this slug fails.
|
|
func (s *ChecklistTemplateService) Delete(ctx context.Context, userID uuid.UUID, slug string) error {
|
|
row, err := s.requireOwnerOrAdmin(ctx, userID, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin delete tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
`DELETE FROM paliad.checklists WHERE id = $1`, row.ID); err != nil {
|
|
return fmt.Errorf("delete checklist: %w", err)
|
|
}
|
|
|
|
actorEmail, _ := s.actorEmail(ctx, userID)
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.deleted",
|
|
ActorID: userID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"was_visibility": row.Visibility,
|
|
},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListOwnedBy returns every authored template owned by the caller. Used
|
|
// by the 'Meine Vorlagen' tab on /checklists.
|
|
func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.UUID) ([]models.Checklist, error) {
|
|
rows := []models.Checklist{}
|
|
if err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT id, slug, owner_id, title, description, regime, court, reference,
|
|
deadline, lang, body, visibility, promoted_at, promoted_by,
|
|
created_at, updated_at
|
|
FROM paliad.checklists
|
|
WHERE owner_id = $1
|
|
ORDER BY updated_at DESC`, userID); err != nil {
|
|
return nil, fmt.Errorf("list owned checklists: %w", err)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
// GetBySlug returns one authored template by slug; applies visibility.
|
|
func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
|
|
var row models.Checklist
|
|
q := `SELECT id, slug, owner_id, title, description, regime, court, reference,
|
|
deadline, lang, body, visibility, promoted_at, promoted_by,
|
|
created_at, updated_at
|
|
FROM paliad.checklists
|
|
WHERE slug = $2
|
|
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
|
|
err := s.db.GetContext(ctx, &row, q, userID, slug)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotVisible
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch checklist: %w", err)
|
|
}
|
|
return &row, nil
|
|
}
|
|
|
|
// --- internals ------------------------------------------------------------
|
|
|
|
// requireOwnerOrAdmin fetches the row and returns it iff caller is owner
|
|
// or global_admin. Other callers get ErrForbidden (template visible to
|
|
// many users, only some can mutate).
|
|
func (s *ChecklistTemplateService) requireOwnerOrAdmin(ctx context.Context, userID uuid.UUID, slug string) (*models.Checklist, error) {
|
|
row, err := s.GetBySlug(ctx, userID, slug)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if row.OwnerID == userID {
|
|
return row, nil
|
|
}
|
|
user, err := s.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user != nil && user.GlobalRole == "global_admin" {
|
|
return row, nil
|
|
}
|
|
return nil, fmt.Errorf("%w: only the owner or a global_admin can modify this checklist", ErrForbidden)
|
|
}
|
|
|
|
func (s *ChecklistTemplateService) 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
|
|
}
|
|
|
|
// generateSlug builds a 'u-<title-slug>-<6hex>' slug. Three retries on
|
|
// collision (against authored table + static catalog). After three
|
|
// failures we fall back to a pure-random suffix so the create path
|
|
// never wedges.
|
|
func (s *ChecklistTemplateService) generateSlug(ctx context.Context, title string) (string, error) {
|
|
base := slugifyTitle(title)
|
|
if base == "" {
|
|
base = "checklist"
|
|
}
|
|
for attempt := 0; attempt < 3; attempt++ {
|
|
suffix, err := randomHex(3)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
slug := "u-" + base + "-" + suffix
|
|
if len(slug) > 64 {
|
|
slug = slug[:64]
|
|
}
|
|
taken, err := s.slugTaken(ctx, slug)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !taken {
|
|
return slug, nil
|
|
}
|
|
}
|
|
suffix, err := randomHex(6)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "u-" + suffix, nil
|
|
}
|
|
|
|
func (s *ChecklistTemplateService) slugTaken(ctx context.Context, slug string) (bool, error) {
|
|
if s.catalog.IsStaticSlug(slug) {
|
|
return true, nil
|
|
}
|
|
var n int
|
|
if err := s.db.GetContext(ctx, &n,
|
|
`SELECT count(*) FROM paliad.checklists WHERE slug = $1`, slug); err != nil {
|
|
return false, fmt.Errorf("slug taken check: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// --- pure helpers ---------------------------------------------------------
|
|
|
|
func slugifyTitle(title string) string {
|
|
s := strings.ToLower(strings.TrimSpace(title))
|
|
s = strings.ReplaceAll(s, "ä", "ae")
|
|
s = strings.ReplaceAll(s, "ö", "oe")
|
|
s = strings.ReplaceAll(s, "ü", "ue")
|
|
s = strings.ReplaceAll(s, "ß", "ss")
|
|
s = slugSafeChars.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
if len(s) > 40 {
|
|
s = s[:40]
|
|
}
|
|
return strings.Trim(s, "-")
|
|
}
|
|
|
|
func randomHex(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("rand: %w", err)
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func requireNonEmptyTrimmed(v, field string, max int) (string, error) {
|
|
t := strings.TrimSpace(v)
|
|
if t == "" {
|
|
return "", fmt.Errorf("%w: %s is required", ErrInvalidInput, field)
|
|
}
|
|
if len(t) > max {
|
|
return "", fmt.Errorf("%w: %s exceeds %d characters", ErrInvalidInput, field, max)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func clampFreeText(v string, max int) string {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) > max {
|
|
v = v[:max]
|
|
}
|
|
return v
|
|
}
|
|
|
|
func normaliseRegime(v string) (string, error) {
|
|
r := strings.ToUpper(strings.TrimSpace(v))
|
|
if r == "" {
|
|
r = "OTHER"
|
|
}
|
|
if !validRegimes[r] {
|
|
return "", fmt.Errorf("%w: regime must be UPC | DE | EPA | OTHER, got %q", ErrInvalidInput, v)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func normaliseLang(v string) (string, error) {
|
|
l := strings.ToLower(strings.TrimSpace(v))
|
|
if l == "" {
|
|
l = "de"
|
|
}
|
|
if !validLangs[l] {
|
|
return "", fmt.Errorf("%w: lang must be de | en, got %q", ErrInvalidInput, v)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func normaliseSliceAVisibility(v string) (string, error) {
|
|
x := strings.ToLower(strings.TrimSpace(v))
|
|
if x == "" {
|
|
x = "private"
|
|
}
|
|
if !validVisibilities[x] {
|
|
return "", fmt.Errorf("%w: visibility must be private | firm | shared, got %q (global is admin-only)", ErrInvalidInput, v)
|
|
}
|
|
return x, nil
|
|
}
|
|
|
|
// validateBodyShape enforces { "groups": [...] } with at least one
|
|
// non-empty group and at least one non-empty item somewhere. Authored
|
|
// templates aren't useful without content.
|
|
func validateBodyShape(body json.RawMessage) error {
|
|
if len(body) == 0 {
|
|
return fmt.Errorf("%w: body is required", ErrInvalidInput)
|
|
}
|
|
var shape struct {
|
|
Groups []checklists.Group `json:"groups"`
|
|
}
|
|
if err := json.Unmarshal(body, &shape); err != nil {
|
|
return fmt.Errorf("%w: body must be {\"groups\":[...]} (%v)", ErrInvalidInput, err)
|
|
}
|
|
if len(shape.Groups) == 0 {
|
|
return fmt.Errorf("%w: body must contain at least one group", ErrInvalidInput)
|
|
}
|
|
totalItems := 0
|
|
for _, g := range shape.Groups {
|
|
totalItems += len(g.Items)
|
|
}
|
|
if totalItems == 0 {
|
|
return fmt.Errorf("%w: body must contain at least one item", ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|