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--<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 }