m/paliad#61 Slice C backend. Schema (mig 116, idempotent): - ALTER paliad.checklists ADD COLUMN version int NOT NULL DEFAULT 1. Pre-Slice-C rows default to 1 (the column was added with DEFAULT so the UPDATE clause is a no-op safety net). - ALTER paliad.checklist_instances ADD COLUMN template_version int. NULL on existing rows — instance detail page leaves the "outdated" badge off when the snapshot version is unknown. Services: - ChecklistTemplateService.Update — version bumps on title/body changes (the meaningful edits that warrant notifying instance owners). Pure metadata tweaks (description/court/reference/deadline) update updated_at without bumping. Emits the new 'checklist.versioned' audit event with prior_version + new_version metadata. - ChecklistInstanceService.Create — captures snapshot_version alongside the body snapshot. - ChecklistCatalogService — CatalogEntry grew a Version field (1 for static; live column for authored). ListVisible / Find populate it. - Models — Checklist.Version int; ChecklistInstance.TemplateVersion *int. - /api/checklists/{slug} response now includes version so the instance detail page can compare against the snapshot. Migration verified live via BEGIN..ROLLBACK against paliad.checklists and paliad.checklist_instances. Build hygiene: go build/vet/test ./internal/... + TestBootSmoke ./cmd/server/ all green.
587 lines
17 KiB
Go
587 lines
17 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
|
|
}
|
|
|
|
// Version bump (Slice C). Title and body are the meaningful edits
|
|
// that warrant a "your snapshot is outdated" badge on existing
|
|
// instances. Pure metadata tweaks (description / court / reference
|
|
// / deadline) update updated_at but don't bump version — we don't
|
|
// want every typo correction to nag users with an outdated badge.
|
|
versionBumped := false
|
|
for _, f := range changed {
|
|
if f == "title" || f == "body" {
|
|
versionBumped = true
|
|
break
|
|
}
|
|
}
|
|
if versionBumped {
|
|
sets = append(sets, "version = version + 1")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Slice C — emit a separate 'checklist.versioned' event when the
|
|
// edit actually bumped the version. Dashboards / future popularity
|
|
// sort can read this without parsing changed_fields[].
|
|
if versionBumped {
|
|
if err := s.audit.WriteChecklistEvent(ctx, tx, ChecklistAuditEvent{
|
|
EventType: "checklist.versioned",
|
|
ActorID: userID,
|
|
ActorEmail: actorEmail,
|
|
Metadata: map[string]any{
|
|
"checklist_id": row.ID,
|
|
"slug": slug,
|
|
"prior_version": row.Version,
|
|
"new_version": row.Version + 1,
|
|
},
|
|
}); 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,
|
|
version, 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,
|
|
version, 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
|
|
}
|