feat(checklists): t-paliad-225 Slice C backend — template versioning + catalog Version
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.
This commit is contained in:
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
DROP COLUMN IF EXISTS template_version;
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
DROP COLUMN IF EXISTS version;
|
||||
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
|
||||
--
|
||||
-- Adds an integer version counter to paliad.checklists that bumps on
|
||||
-- every meaningful edit (body or title — see
|
||||
-- ChecklistTemplateService.Update). Adds a matching template_version
|
||||
-- column on paliad.checklist_instances so the instance detail page can
|
||||
-- surface "the template you instantiated from has been updated" and
|
||||
-- offer a diff view.
|
||||
--
|
||||
-- Existing rows backfill to version=1 / template_version=NULL. The
|
||||
-- NULL on instances means "we don't know which version was snapshotted"
|
||||
-- (pre-Slice-C row); the snapshot column is still authoritative for
|
||||
-- rendering, but the "outdated" badge stays off because we can't
|
||||
-- compare.
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
|
||||
|
||||
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
|
||||
-- DEFAULT 1, but defensive — the column was added with default so this
|
||||
-- is a no-op on the live DB).
|
||||
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklists.version IS
|
||||
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
|
||||
'whenever body or title changes. Used by the instance detail page '
|
||||
'to show an "outdated" badge when the user''s snapshot is older.';
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_version int;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
|
||||
'Snapshot of paliad.checklists.version at instance create time. '
|
||||
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
|
||||
'"outdated" badge stays off in that case.';
|
||||
@@ -124,12 +124,16 @@ func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Re-render as the bilingual Template shape plus a thin meta block.
|
||||
// Version is included so the instance detail page can decide whether
|
||||
// to show the "template updated since this instance was created"
|
||||
// badge (Slice C).
|
||||
type templateWithMeta struct {
|
||||
checklists.Template
|
||||
Origin string `json:"origin"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, templateWithMeta{
|
||||
Template: entry.Template,
|
||||
@@ -137,6 +141,7 @@ func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
|
||||
Visibility: entry.Visibility,
|
||||
OwnerEmail: entry.OwnerEmail,
|
||||
OwnerDisplayName: entry.OwnerDisplayName,
|
||||
Version: entry.Version,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,10 @@ type ChecklistInstance struct {
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
TemplateSnapshot NullableJSON `db:"template_snapshot" json:"template_snapshot,omitempty"`
|
||||
// TemplateVersion is the checklists.version at instance create time.
|
||||
// NULL on pre-Slice-C rows where versioning wasn't captured; the
|
||||
// "outdated" badge stays off in that case.
|
||||
TemplateVersion *int `db:"template_version" json:"template_version,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistInstanceWithProject enriches an instance with its parent Project
|
||||
@@ -471,6 +475,7 @@ type Checklist struct {
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
PromotedAt *time.Time `db:"promoted_at" json:"promoted_at,omitempty"`
|
||||
PromotedBy *uuid.UUID `db:"promoted_by" json:"promoted_by,omitempty"`
|
||||
Version int `db:"version" json:"version"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -51,7 +51,11 @@ type CatalogEntry struct {
|
||||
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
Template checklists.Template `json:"template"`
|
||||
// Version of the underlying row. 1 for static templates (they
|
||||
// re-version implicitly with the deploy that ships them); the live
|
||||
// counter from paliad.checklists.version for authored rows.
|
||||
Version int `json:"version"`
|
||||
Template checklists.Template `json:"template"`
|
||||
}
|
||||
|
||||
// IsStaticSlug reports whether the given slug names a curated static
|
||||
@@ -74,6 +78,7 @@ func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.U
|
||||
Slug: t.Slug,
|
||||
Origin: "static",
|
||||
Visibility: "static",
|
||||
Version: 1,
|
||||
Template: t,
|
||||
})
|
||||
}
|
||||
@@ -108,6 +113,7 @@ func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.U
|
||||
OwnerID: &ownerID,
|
||||
OwnerEmail: r.OwnerEmail,
|
||||
OwnerDisplayName: r.OwnerDisplayName,
|
||||
Version: r.Version,
|
||||
Template: tpl,
|
||||
})
|
||||
}
|
||||
@@ -123,6 +129,7 @@ func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, sl
|
||||
Slug: t.Slug,
|
||||
Origin: "static",
|
||||
Visibility: "static",
|
||||
Version: 1,
|
||||
Template: t,
|
||||
}, nil
|
||||
}
|
||||
@@ -148,6 +155,7 @@ func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, sl
|
||||
OwnerID: &ownerID,
|
||||
OwnerEmail: row.OwnerEmail,
|
||||
OwnerDisplayName: row.OwnerDisplayName,
|
||||
Version: row.Version,
|
||||
Template: tpl,
|
||||
}, nil
|
||||
}
|
||||
@@ -172,7 +180,7 @@ func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.
|
||||
|
||||
const authoredWithOwnerSelect = `SELECT c.id, c.slug, c.owner_id, c.title, c.description,
|
||||
c.regime, c.court, c.reference, c.deadline, c.lang, c.body, c.visibility,
|
||||
c.promoted_at, c.promoted_by, c.created_at, c.updated_at,
|
||||
c.promoted_at, c.promoted_by, c.version, c.created_at, c.updated_at,
|
||||
u.email AS owner_email,
|
||||
u.display_name AS owner_display_name
|
||||
FROM paliad.checklists c
|
||||
|
||||
@@ -36,7 +36,7 @@ func NewChecklistInstanceService(db *sqlx.DB, projects *ProjectService, catalog
|
||||
}
|
||||
|
||||
const checklistInstanceColumns = `ci.id, ci.template_slug, ci.name, ci.project_id, ci.state,
|
||||
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot`
|
||||
ci.created_by, ci.created_at, ci.updated_at, ci.template_snapshot, ci.template_version`
|
||||
|
||||
const checklistInstanceWithProjectSelect = `SELECT ` + checklistInstanceColumns + `,
|
||||
p.reference AS project_reference,
|
||||
@@ -136,7 +136,8 @@ func (s *ChecklistInstanceService) GetByID(ctx context.Context, userID, id uuid.
|
||||
// catalog so subsequent template edits/deletes don't disturb this row
|
||||
// (t-paliad-225 Slice A).
|
||||
func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID, slug string, input CreateInstanceInput) (*models.ChecklistInstance, error) {
|
||||
if _, err := s.catalog.Find(ctx, userID, slug); err != nil {
|
||||
entry, err := s.catalog.Find(ctx, userID, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, fmt.Errorf("%w: unknown template slug %q", ErrInvalidInput, slug)
|
||||
}
|
||||
@@ -146,6 +147,10 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot template body: %w", err)
|
||||
}
|
||||
// Slice C — capture the version we snapshotted from so the instance
|
||||
// detail page can show "template updated since this instance was
|
||||
// created" when the live version pulls ahead.
|
||||
snapshotVersion := entry.Version
|
||||
name := strings.TrimSpace(input.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("%w: name is required", ErrInvalidInput)
|
||||
@@ -171,9 +176,9 @@ func (s *ChecklistInstanceService) Create(ctx context.Context, userID uuid.UUID,
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.checklist_instances
|
||||
(id, template_slug, name, project_id, state, template_snapshot,
|
||||
created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $7)`,
|
||||
id, slug, name, input.ProjectID, string(snapshot), userID, now,
|
||||
template_version, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb, $5::jsonb, $6, $7, $8, $8)`,
|
||||
id, slug, name, input.ProjectID, string(snapshot), snapshotVersion, userID, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert checklist_instance: %w", err)
|
||||
}
|
||||
@@ -385,7 +390,7 @@ func (s *ChecklistInstanceService) getByIDUnchecked(ctx context.Context, id uuid
|
||||
var inst models.ChecklistInstance
|
||||
err := s.db.GetContext(ctx, &inst,
|
||||
`SELECT id, template_slug, name, project_id, state, created_by,
|
||||
created_at, updated_at, template_snapshot
|
||||
created_at, updated_at, template_snapshot, template_version
|
||||
FROM paliad.checklist_instances WHERE id = $1`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
|
||||
@@ -217,6 +217,23 @@ func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID,
|
||||
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)
|
||||
|
||||
@@ -246,6 +263,25 @@ func (s *ChecklistTemplateService) Update(ctx context.Context, userID uuid.UUID,
|
||||
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)
|
||||
}
|
||||
@@ -343,7 +379,7 @@ func (s *ChecklistTemplateService) ListOwnedBy(ctx context.Context, userID uuid.
|
||||
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
|
||||
version, created_at, updated_at
|
||||
FROM paliad.checklists
|
||||
WHERE owner_id = $1
|
||||
ORDER BY updated_at DESC`, userID); err != nil {
|
||||
@@ -357,7 +393,7 @@ func (s *ChecklistTemplateService) GetBySlug(ctx context.Context, userID uuid.UU
|
||||
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
|
||||
version, created_at, updated_at
|
||||
FROM paliad.checklists
|
||||
WHERE slug = $2
|
||||
AND ` + checklistVisibilityPredicate("paliad.checklists", 1)
|
||||
|
||||
Reference in New Issue
Block a user