diff --git a/apps/web/app/admin/courses/[year]/[code]/[[...section]]/page.tsx b/apps/web/app/admin/courses/[year]/[code]/[[...section]]/page.tsx index f3e42ec9..816ee09f 100644 --- a/apps/web/app/admin/courses/[year]/[code]/[[...section]]/page.tsx +++ b/apps/web/app/admin/courses/[year]/[code]/[[...section]]/page.tsx @@ -1,9 +1,18 @@ +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages"; export const dynamic = "force-dynamic"; export default async function Page({ params, + searchParams, }: { params: Promise<{ year: string; code: string; section?: string[] }>; + searchParams: SearchParams; }) { - return ; + return ( + + ); } diff --git a/apps/web/app/admin/majors/[year]/[code]/[[...section]]/page.tsx b/apps/web/app/admin/majors/[year]/[code]/[[...section]]/page.tsx index fea80fdf..6fe101d9 100644 --- a/apps/web/app/admin/majors/[year]/[code]/[[...section]]/page.tsx +++ b/apps/web/app/admin/majors/[year]/[code]/[[...section]]/page.tsx @@ -1,9 +1,18 @@ +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages"; export const dynamic = "force-dynamic"; export default async function Page({ params, + searchParams, }: { params: Promise<{ year: string; code: string; section?: string[] }>; + searchParams: SearchParams; }) { - return ; + return ( + + ); } diff --git a/apps/web/app/admin/minors/[year]/[code]/[[...section]]/page.tsx b/apps/web/app/admin/minors/[year]/[code]/[[...section]]/page.tsx index e3e6f16f..1bb50101 100644 --- a/apps/web/app/admin/minors/[year]/[code]/[[...section]]/page.tsx +++ b/apps/web/app/admin/minors/[year]/[code]/[[...section]]/page.tsx @@ -1,9 +1,18 @@ +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages"; export const dynamic = "force-dynamic"; export default async function Page({ params, + searchParams, }: { params: Promise<{ year: string; code: string; section?: string[] }>; + searchParams: SearchParams; }) { - return ; + return ( + + ); } diff --git a/apps/web/app/admin/programmes/[year]/[code]/[[...section]]/page.tsx b/apps/web/app/admin/programmes/[year]/[code]/[[...section]]/page.tsx index bc34635f..457bfa91 100644 --- a/apps/web/app/admin/programmes/[year]/[code]/[[...section]]/page.tsx +++ b/apps/web/app/admin/programmes/[year]/[code]/[[...section]]/page.tsx @@ -1,9 +1,18 @@ +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages"; export const dynamic = "force-dynamic"; export default async function Page({ params, + searchParams, }: { params: Promise<{ year: string; code: string; section?: string[] }>; + searchParams: SearchParams; }) { - return ; + return ( + + ); } diff --git a/apps/web/app/admin/specialisations/[year]/[code]/[[...section]]/page.tsx b/apps/web/app/admin/specialisations/[year]/[code]/[[...section]]/page.tsx index e1adf57d..ad02a740 100644 --- a/apps/web/app/admin/specialisations/[year]/[code]/[[...section]]/page.tsx +++ b/apps/web/app/admin/specialisations/[year]/[code]/[[...section]]/page.tsx @@ -1,9 +1,18 @@ +import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages"; import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages"; export const dynamic = "force-dynamic"; export default async function Page({ params, + searchParams, }: { params: Promise<{ year: string; code: string; section?: string[] }>; + searchParams: SearchParams; }) { - return ; + return ( + + ); } diff --git a/apps/web/lib/catalogue/changelog.ts b/apps/web/lib/catalogue/changelog.ts new file mode 100644 index 00000000..0469c725 --- /dev/null +++ b/apps/web/lib/catalogue/changelog.ts @@ -0,0 +1,183 @@ +import { fieldLabel } from "../coursemap/catalogue-kinds.ts"; + +export type ChangelogEventKind = + | "edit" + | "publish" + | "unpublish" + | "discard" + | "restore" + | "source_draft_created" + | "source_checked" + | "source_changed" + | "sync_failed" + | "source_accepted" + | "source_kept"; + +export type ChangelogEvent = { + id: number; + eventKind: ChangelogEventKind; + origin: "manual" | "source"; + actorId: string | null; + editingSessionId: string | null; + versionId: number | null; + syncChangeId: number | null; + createdAt: string; + fields: Array<{ fieldPath: string; oldValue: unknown; newValue: unknown }>; + /** The review row a source decision answered, when the event has one. */ + decision: { fieldPath: string; decision: "use_source" | "keep_local" } | null; +}; + +export type ChangelogFieldChange = { + fieldPath: string; + label: string; + oldValue: unknown; + newValue: unknown; +}; + +export type ChangelogEntry = { + id: string; + kind: ChangelogEventKind; + at: string; + startedAt: string; + actorId: string | null; + origin: "manual" | "source"; + eventIds: number[]; + versionId: number | null; + fields: ChangelogFieldChange[]; + usedFromSource: string[]; + keptLocal: string[]; +}; + +/** Decisions taken in one sitting read as one review, not as five rows. */ +const DECISION_WINDOW_MS = 30 * 60 * 1000; + +function sameEditingSession(left: ChangelogEvent, right: ChangelogEvent) { + return ( + left.eventKind === "edit" && + right.eventKind === "edit" && + left.editingSessionId !== null && + left.editingSessionId === right.editingSessionId && + left.actorId === right.actorId + ); +} + +function sameDecisionSitting(left: ChangelogEvent, right: ChangelogEvent) { + const decisions = new Set(["source_accepted", "source_kept"]); + return ( + decisions.has(left.eventKind) && + decisions.has(right.eventKind) && + left.actorId === right.actorId && + Math.abs(Date.parse(left.createdAt) - Date.parse(right.createdAt)) <= + DECISION_WINDOW_MS + ); +} + +function sameQuietCheck(left: ChangelogEvent, right: ChangelogEvent) { + return ( + left.eventKind === "source_checked" && right.eventKind === "source_checked" + ); +} + +function belongsToGroup(group: ChangelogEvent[], event: ChangelogEvent) { + const last = group[group.length - 1]!; + return ( + sameEditingSession(last, event) || + sameDecisionSitting(last, event) || + sameQuietCheck(last, event) + ); +} + +/** + * Collapses one group of raw events into the field changes a reader cares + * about: the value before the session started against the value it ended on, + * with paths that came back to where they started dropped entirely. + */ +function mergedFields(events: ChangelogEvent[]): ChangelogFieldChange[] { + const oldest = new Map(); + const newest = new Map(); + const order: string[] = []; + // Events arrive newest first, so the last one seen for a path is the oldest. + for (const event of events) { + for (const change of event.fields) { + if (!newest.has(change.fieldPath)) { + newest.set(change.fieldPath, change.newValue); + order.push(change.fieldPath); + } + oldest.set(change.fieldPath, change.oldValue); + } + } + return order.flatMap((fieldPath) => { + const oldValue = oldest.get(fieldPath) ?? null; + const newValue = newest.get(fieldPath) ?? null; + if (JSON.stringify(oldValue ?? null) === JSON.stringify(newValue ?? null)) + return []; + return [{ fieldPath, label: fieldLabel(fieldPath), oldValue, newValue }]; + }); +} + +/** + * One human timeline from raw audit events, newest first. Grouping is + * presentation only: every event keeps its own row in the database, and an + * entry names the events it covers so the raw history stays reachable. + */ +export function groupChangelogEvents( + events: readonly ChangelogEvent[], +): ChangelogEntry[] { + const groups: ChangelogEvent[][] = []; + for (const event of events) { + const current = groups[groups.length - 1]; + if (current && belongsToGroup(current, event)) current.push(event); + else groups.push([event]); + } + + return groups.map((group) => { + const newest = group[0]!; + const oldest = group[group.length - 1]!; + const decisions = group.flatMap((event) => + event.decision ? [event.decision] : [], + ); + return { + id: `event-${newest.id}`, + kind: newest.eventKind, + at: newest.createdAt, + startedAt: oldest.createdAt, + actorId: newest.actorId, + origin: newest.origin, + eventIds: group.map((event) => event.id), + versionId: newest.versionId, + fields: mergedFields(group), + usedFromSource: decisions + .filter((entry) => entry.decision === "use_source") + .map((entry) => fieldLabel(entry.fieldPath)), + keptLocal: decisions + .filter((entry) => entry.decision === "keep_local") + .map((entry) => fieldLabel(entry.fieldPath)), + } satisfies ChangelogEntry; + }); +} + +export const CHANGELOG_PAGE_SIZE = 40; + +export type ChangelogEntryView = ChangelogEntry & { + actorName: string | null; + versionOrdinal: number | null; + /** Review totals for the sync that produced a source change entry. */ + sourceChanges: { total: number; conflicts: number } | null; +}; + +export type CatalogueChangelog = { + entries: ChangelogEntryView[]; + shown: number; + hasMore: boolean; +}; + +/** "5 autosaves over 4 minutes", or null when there is nothing to summarise. */ +export function editingSessionSummary(entry: ChangelogEntry) { + if (entry.kind !== "edit" || entry.eventIds.length < 2) return null; + const minutes = Math.round( + (Date.parse(entry.at) - Date.parse(entry.startedAt)) / 60_000, + ); + const saves = `${entry.eventIds.length} autosaves`; + if (minutes < 1) return `${saves} in under a minute`; + return `${saves} over ${minutes} minute${minutes === 1 ? "" : "s"}`; +} diff --git a/apps/web/lib/catalogue/drafts.ts b/apps/web/lib/catalogue/drafts.ts index 75c5e176..3f8f963c 100644 --- a/apps/web/lib/catalogue/drafts.ts +++ b/apps/web/lib/catalogue/drafts.ts @@ -605,6 +605,7 @@ export async function restoreCatalogueVersion({ select * from public.catalogue_drafts where record_id = ${recordId} for update `; const existing = existingRow ? draftFromRow(existingRow) : null; + let replacedVersionId: number | null = null; if (existing) { if (!replaceExistingDraft) throw new CatalogueDraftError( @@ -613,6 +614,24 @@ export async function restoreCatalogueVersion({ ); if (expectedRevision === null || existing.revision !== expectedRevision) throw new CatalogueDraftConflictError(existing.revision); + // Restoring must not destroy work. The draft it replaces becomes a + // version of its own, so the changelog can offer it back. + if (await draftIsMeaningful(tx, record, existing)) { + replacedVersionId = await materialiseDraftVersion(tx, { + record, + draft: existing, + userId, + }); + await tx` + insert into public.catalogue_change_events ( + record_id, draft_revision, event_kind, origin, actor_id, + editing_session_id, version_id + ) values ( + ${recordId}, ${existing.revision}, 'discard', 'manual', + ${userId}::uuid, ${editingSessionId}::uuid, ${replacedVersionId} + ) + `; + } } const revision = existing ? existing.revision + 1 : 0; const contentHash = contentHashForCatalogueContent(content); @@ -648,7 +667,7 @@ export async function restoreCatalogueVersion({ ${editingSessionId}::uuid, ${versionId} ) `; - return { revision, content: restored }; + return { revision, content: restored, replacedVersionId }; }); return sql ? work(sql) : withSyncDatabaseClient(work); } diff --git a/apps/web/lib/catalogue/source-review-decisions.ts b/apps/web/lib/catalogue/source-review-decisions.ts index d3c5e94b..af39465e 100644 --- a/apps/web/lib/catalogue/source-review-decisions.ts +++ b/apps/web/lib/catalogue/source-review-decisions.ts @@ -160,10 +160,11 @@ export async function resolveSourceChange({ `; const [event] = await tx` insert into public.catalogue_change_events ( - record_id, draft_revision, event_kind, origin, actor_id, version_id + record_id, draft_revision, event_kind, origin, actor_id, version_id, + sync_change_id ) values ( ${recordId}, ${revision}, 'source_accepted', 'source', - ${userId}::uuid, ${sourceVersionId} + ${userId}::uuid, ${sourceVersionId}, ${changeId} ) returning id `; for (const [position, change] of fieldChanges.entries()) { @@ -186,10 +187,11 @@ export async function resolveSourceChange({ } else { await tx` insert into public.catalogue_change_events ( - record_id, draft_revision, event_kind, origin, actor_id, version_id + record_id, draft_revision, event_kind, origin, actor_id, version_id, + sync_change_id ) values ( ${recordId}, ${revision}, 'source_kept', 'source', ${userId}::uuid, - ${sourceVersionId} + ${sourceVersionId}, ${changeId} ) `; } diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 7e5f9d59..20795bd5 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -215,7 +215,9 @@ export async function restoreCatalogueVersionAction({ return { ok: true, revision: result.revision, - message: "Version restored as a draft.", + message: result.replacedVersionId + ? "Version restored as a draft. Your previous draft is in the Changelog." + : "Version restored as a draft.", }; } catch (error) { return draftFailure(error, "The version could not be restored."); diff --git a/apps/web/lib/coursemap/admin-catalogue-changelog.ts b/apps/web/lib/coursemap/admin-catalogue-changelog.ts new file mode 100644 index 00000000..220ef036 --- /dev/null +++ b/apps/web/lib/coursemap/admin-catalogue-changelog.ts @@ -0,0 +1,169 @@ +import "server-only"; +import { + CHANGELOG_PAGE_SIZE, + type CatalogueChangelog, + type ChangelogEntryView, + type ChangelogEvent, + groupChangelogEvents, +} from "@/lib/catalogue/changelog"; +import { createClient } from "@/lib/supabase/server"; + +/** + * The record's timeline, newest first. Raw events are never rewritten; the + * grouping here is presentation, and an entry carries the event ids it covers. + * A group is not stitched across a page boundary, so the oldest entry on a + * page can be part of a session that continues on the next one. + */ +export async function loadCatalogueChangelog({ + recordId, + limit = CHANGELOG_PAGE_SIZE, +}: { + recordId: number; + limit?: number; +}): Promise { + const supabase = await createClient(); + const { data: eventRows, error } = await supabase + .from("catalogue_change_events") + .select( + "id,event_kind,origin,actor_id,editing_session_id,version_id,sync_change_id,created_at", + ) + .eq("record_id", recordId) + .order("created_at", { ascending: false }) + .order("id", { ascending: false }) + .limit(limit + 1); + if (error) throw error; + const hasMore = (eventRows?.length ?? 0) > limit; + const rows = (eventRows ?? []).slice(0, limit); + if (rows.length === 0) return { entries: [], shown: 0, hasMore: false }; + + const eventIds = rows.map((row) => row.id); + const syncChangeIds = rows.flatMap((row) => + row.sync_change_id === null ? [] : [row.sync_change_id], + ); + const actorIds = [ + ...new Set(rows.flatMap((row) => (row.actor_id ? [row.actor_id] : []))), + ]; + const sourceVersionIds = rows.flatMap((row) => + row.event_kind === "source_changed" && row.version_id !== null + ? [row.version_id] + : [], + ); + + const [fieldChanges, decisions, actors, versions, sourceVersions] = + await Promise.all([ + supabase + .from("catalogue_field_changes") + .select("event_id,position,field_path,old_value,new_value") + .in("event_id", eventIds) + .order("position"), + syncChangeIds.length + ? supabase + .from("catalogue_sync_changes") + .select("id,field_path,decision") + .in("id", syncChangeIds) + : { data: [], error: null }, + actorIds.length + ? supabase + .from("admin_users") + .select("user_id,display_name") + .in("user_id", actorIds) + : { data: [], error: null }, + supabase + .from("catalogue_versions") + .select("id") + .eq("record_id", recordId) + .order("id"), + sourceVersionIds.length + ? supabase + .from("catalogue_versions") + .select("id,sync_id") + .in("id", sourceVersionIds) + : { data: [], error: null }, + ]); + if (fieldChanges.error) throw fieldChanges.error; + if (decisions.error) throw decisions.error; + if (actors.error) throw actors.error; + if (versions.error) throw versions.error; + if (sourceVersions.error) throw sourceVersions.error; + + const syncIds = (sourceVersions.data ?? []).flatMap((version) => + version.sync_id ? [version.sync_id] : [], + ); + const reviewRows = syncIds.length + ? await supabase + .from("catalogue_sync_changes") + .select("sync_id,classification") + .in("sync_id", syncIds) + .in("classification", ["source_change", "conflict"]) + : { data: [], error: null }; + if (reviewRows.error) throw reviewRows.error; + + const fieldsByEvent = new Map(); + for (const change of fieldChanges.data ?? []) { + const list = fieldsByEvent.get(change.event_id) ?? []; + list.push({ + fieldPath: change.field_path, + oldValue: change.old_value, + newValue: change.new_value, + }); + fieldsByEvent.set(change.event_id, list); + } + const decisionById = new Map( + (decisions.data ?? []).map((row) => [ + row.id, + { + fieldPath: row.field_path, + decision: row.decision as "use_source" | "keep_local", + }, + ]), + ); + const actorNames = new Map( + (actors.data ?? []).flatMap((actor) => + actor.user_id && actor.display_name + ? [[actor.user_id, actor.display_name] as const] + : [], + ), + ); + const ordinalByVersion = new Map( + (versions.data ?? []).map((version, index) => [version.id, index + 1]), + ); + const syncByVersion = new Map( + (sourceVersions.data ?? []).map((version) => [version.id, version.sync_id]), + ); + const reviewCounts = new Map(); + for (const row of reviewRows.data ?? []) { + const counts = reviewCounts.get(row.sync_id) ?? { total: 0, conflicts: 0 }; + counts.total += 1; + if (row.classification === "conflict") counts.conflicts += 1; + reviewCounts.set(row.sync_id, counts); + } + + const events: ChangelogEvent[] = rows.map((row) => ({ + id: row.id, + eventKind: row.event_kind as ChangelogEvent["eventKind"], + origin: row.origin as ChangelogEvent["origin"], + actorId: row.actor_id, + editingSessionId: row.editing_session_id, + versionId: row.version_id, + syncChangeId: row.sync_change_id, + createdAt: row.created_at, + fields: fieldsByEvent.get(row.id) ?? [], + decision: + row.sync_change_id === null + ? null + : (decisionById.get(row.sync_change_id) ?? null), + })); + + const entries = groupChangelogEvents(events).map((entry) => { + const syncId = entry.versionId ? syncByVersion.get(entry.versionId) : null; + return { + ...entry, + actorName: entry.actorId ? (actorNames.get(entry.actorId) ?? null) : null, + versionOrdinal: entry.versionId + ? (ordinalByVersion.get(entry.versionId) ?? null) + : null, + sourceChanges: syncId ? (reviewCounts.get(syncId) ?? null) : null, + } satisfies ChangelogEntryView; + }); + return { entries, shown: rows.length, hasMore }; +} diff --git a/apps/web/lib/coursemap/admin-catalogue-record.ts b/apps/web/lib/coursemap/admin-catalogue-record.ts index ee7adf68..1c9fa026 100644 --- a/apps/web/lib/coursemap/admin-catalogue-record.ts +++ b/apps/web/lib/coursemap/admin-catalogue-record.ts @@ -63,23 +63,6 @@ export type CatalogueRecord = { unpublishedAt: string | null; unpublishedBy: string | null; }>; - changeEvents: Array<{ - id: number; - eventKind: - | "edit" - | "publish" - | "unpublish" - | "discard" - | "restore" - | "source_draft_created" - | "source_checked" - | "source_changed" - | "sync_failed"; - draftRevision: number | null; - editingSessionId: string | null; - versionId: number | null; - createdAt: string; - }>; syncs: CatalogueSync[]; }; @@ -134,7 +117,6 @@ export async function loadCatalogueRecord({ syncsResult, blockersResult, listingResult, - changeEventsResult, ] = await Promise.all([ supabase .from("catalogue_versions") @@ -163,20 +145,12 @@ export async function loadCatalogueRecord({ .select("title,is_current,last_seen_at") .eq("record_id", itemYear.id) .maybeSingle(), - supabase - .from("catalogue_change_events") - .select( - "id,event_kind,draft_revision,editing_session_id,version_id,created_at", - ) - .eq("record_id", itemYear.id) - .order("created_at", { ascending: false }), ]); if (versionsResult.error) throw versionsResult.error; if (publicationsResult.error) throw publicationsResult.error; if (syncsResult.error) throw syncsResult.error; if (blockersResult.error) throw blockersResult.error; if (listingResult.error) throw listingResult.error; - if (changeEventsResult.error) throw changeEventsResult.error; const currentVersionId = itemYear.published_version_id; const title = await versionTitle(supabase, kind, currentVersionId); @@ -215,15 +189,6 @@ export async function loadCatalogueRecord({ unpublishedAt: publication.unpublished_at, unpublishedBy: publication.unpublished_by, })), - changeEvents: (changeEventsResult.data ?? []).map((event) => ({ - id: event.id, - eventKind: - event.event_kind as CatalogueRecord["changeEvents"][number]["eventKind"], - draftRevision: event.draft_revision, - editingSessionId: event.editing_session_id, - versionId: event.version_id, - createdAt: event.created_at, - })), syncs: (syncsResult.data ?? []).map((sync) => ({ id: sync.id, status: sync.status as CatalogueSync["status"], diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index 11a4a53d..2695215a 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -37,6 +37,16 @@ export function adminCatalogueRecordPath( return `${adminCatalogueYearPath(kind, year)}/${encodeURIComponent(code.toLowerCase())}`; } +/** One immutable version, numbered within its record rather than by row id. */ +export function adminCatalogueVersionPath( + kind: CatalogueKind, + year: number, + code: string, + versionOrdinal: number, +) { + return `${adminCatalogueRecordPath(kind, year, code)}/changelog/${versionOrdinal}`; +} + export function publicCatalogueRecordPath( kind: CatalogueKind, year: number, diff --git a/apps/web/playwright/catalogue-workspace.spec.ts b/apps/web/playwright/catalogue-workspace.spec.ts index 01792c27..d29be415 100644 --- a/apps/web/playwright/catalogue-workspace.spec.ts +++ b/apps/web/playwright/catalogue-workspace.spec.ts @@ -55,9 +55,19 @@ test("catalogue content autosaves and remains separate from student view", async await page.getByRole("tab", { name: "Changelog" }).click(); await expect(page).toHaveURL(/\/admin\/courses\/2026\/comp1100\/changelog$/); await expect(page.getByRole("list", { name: "Changelog" })).toBeVisible(); - await expect(page.getByText(/Version \d+ created/).first()).toBeVisible(); await expect( page.getByRole("heading", { name: "Draft discarded" }).first(), ).toBeVisible(); - await expect(page.getByRole("button", { name: /restore/i })).toHaveCount(0); + // The discarded draft was kept as a version, which the entry offers to open. + const checkpoint = page + .getByRole("link", { name: /View version \d+/ }) + .first(); + await expect(checkpoint).toBeVisible(); + await checkpoint.click(); + await expect(page).toHaveURL( + /\/admin\/courses\/2026\/comp1100\/changelog\/\d+$/, + ); + await expect( + page.getByRole("button", { name: "Restore as draft" }), + ).toBeVisible(); }); diff --git a/apps/web/tests/catalogue-changelog-database.test.mjs b/apps/web/tests/catalogue-changelog-database.test.mjs new file mode 100644 index 00000000..1ffaf5da --- /dev/null +++ b/apps/web/tests/catalogue-changelog-database.test.mjs @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { afterAll, beforeAll, test } from "vitest"; + +import { + createCatalogueDraft, + discardCatalogueDraft, + publishCatalogueDraft, + restoreCatalogueVersion, + saveCatalogueDraft, + unpublishCatalogueRecord, +} from "../lib/catalogue/drafts.ts"; +import { readVersionContent } from "../lib/catalogue-import/version-content.ts"; +import { createLocalDatabaseClient } from "../scripts/catalogue/lib/local-database.mjs"; +import { localTestEnvironment } from "../scripts/local/test-environment.mjs"; + +const ADMIN_ID = "99000000-0000-4000-8000-000000000051"; +const CODE = "TSTC9301"; +const YEAR = 2026; +const SESSION_ID = "99000000-0000-4000-8000-000000000052"; +const SECOND_SESSION_ID = "99000000-0000-4000-8000-000000000053"; + +let sql; +let recordId; + +async function removeFixture() { + await sql`delete from public.catalogue_listings where kind = 'course' and code = ${CODE}`; + await sql`alter table public.catalogue_versions disable trigger catalogue_versions_enforce_immutability`; + try { + await sql`delete from public.catalogue_codes where kind = 'course' and code = ${CODE}`; + } finally { + await sql`alter table public.catalogue_versions enable trigger catalogue_versions_enforce_immutability`; + } +} + +async function events() { + return sql` + select id, event_kind, origin, actor_id, editing_session_id, version_id, + draft_revision + from public.catalogue_change_events + where record_id = ${recordId} + order by id + `; +} + +beforeAll(async () => { + Object.assign(process.env, localTestEnvironment(), { + NODE_ENV: "development", + }); + sql = await createLocalDatabaseClient(); + await sql` + insert into auth.users ( + instance_id, id, aud, role, email, raw_app_meta_data, + raw_user_meta_data, created_at, updated_at + ) values ( + '00000000-0000-0000-0000-000000000000', ${ADMIN_ID}, + 'authenticated', 'authenticated', 'changelog-admin@example.test', + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, + now(), now() + ) on conflict (id) do nothing + `; + await removeFixture(); + const [year] = + await sql`select id from public.academic_years where year = ${YEAR}`; + const [code] = await sql` + insert into public.catalogue_codes (kind, code) + values ('course', ${CODE}) returning id + `; + const [record] = await sql` + insert into public.catalogue_records (code_id, kind, academic_year_id) + values (${code.id}, 'course', ${year.id}) returning id + `; + recordId = Number(record.id); + await sql` + insert into public.catalogue_listings ( + academic_year_id, kind, code, title, code_id, record_id, is_current, + first_seen_at, last_seen_at + ) values ( + ${year.id}, 'course', ${CODE}, 'Changelog Systems', ${code.id}, + ${record.id}, true, now(), now() + ) + `; +}); + +afterAll(async () => { + if (!sql) return; + await removeFixture(); + await sql`delete from auth.users where id = ${ADMIN_ID}`; + await sql.end({ timeout: 5 }); +}); + +test("every operation leaves one attributable audit event behind", async () => { + const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + let revision = draft.revision; + let content = draft.content; + for (const description of ["First pass.", "Second pass.", "Third pass."]) { + const next = structuredClone(content); + next.course.details.description = description; + const saved = await saveCatalogueDraft({ + recordId, + expectedRevision: revision, + content: next, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + revision = saved.draft.revision; + content = saved.draft.content; + } + + const autosaves = (await events()).filter( + (event) => event.event_kind === "edit", + ); + assert.equal(autosaves.length, 3); + assert.equal( + new Set(autosaves.map((event) => event.editing_session_id)).size, + 1, + ); + assert.deepEqual( + autosaves.map((event) => Number(event.draft_revision)), + [1, 2, 3], + ); + assert.equal(autosaves[0].actor_id, ADMIN_ID); + + const published = await publishCatalogueDraft({ + recordId, + expectedRevision: revision, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + await unpublishCatalogueRecord({ + recordId, + userId: ADMIN_ID, + editingSessionId: SESSION_ID, + sql, + }); + const lifecycle = (await events()).filter((event) => + ["publish", "unpublish"].includes(event.event_kind), + ); + assert.deepEqual( + lifecycle.map((event) => [event.event_kind, Number(event.version_id)]), + [ + ["publish", published.versionId], + ["unpublish", published.versionId], + ], + ); +}); + +test("restoring a version keeps the draft it replaces", async () => { + const draft = await createCatalogueDraft({ recordId, userId: ADMIN_ID, sql }); + const inProgress = structuredClone(draft.content); + inProgress.course.details.description = "Work in progress worth keeping."; + const saved = await saveCatalogueDraft({ + recordId, + expectedRevision: draft.revision, + content: inProgress, + userId: ADMIN_ID, + editingSessionId: SECOND_SESSION_ID, + sql, + }); + + const [oldest] = await sql` + select id from public.catalogue_versions where record_id = ${recordId} + order by id limit 1 + `; + const restored = await restoreCatalogueVersion({ + recordId, + versionId: Number(oldest.id), + expectedRevision: saved.draft.revision, + replaceExistingDraft: true, + userId: ADMIN_ID, + editingSessionId: SECOND_SESSION_ID, + sql, + }); + assert.ok(restored.replacedVersionId); + + const replaced = await readVersionContent(sql, restored.replacedVersionId); + assert.equal( + replaced.course.details.description, + "Work in progress worth keeping.", + ); + const [checkpoint] = (await events()).filter( + (event) => + event.event_kind === "discard" && + Number(event.version_id) === restored.replacedVersionId, + ); + assert.ok(checkpoint, "the replaced draft is offered back in the changelog"); + + const [restoreEvent] = (await events()).filter( + (event) => event.event_kind === "restore", + ); + assert.equal(Number(restoreEvent.version_id), Number(oldest.id)); + const [oldestAfter] = await sql` + select content_hash, sealed_at from public.catalogue_versions where id = ${oldest.id} + `; + assert.ok(oldestAfter.sealed_at, "the restored version is untouched history"); + + await discardCatalogueDraft({ + recordId, + expectedRevision: restored.revision, + userId: ADMIN_ID, + editingSessionId: SECOND_SESSION_ID, + sql, + }); +}); diff --git a/apps/web/tests/catalogue-changelog-timeline.test.tsx b/apps/web/tests/catalogue-changelog-timeline.test.tsx new file mode 100644 index 00000000..42ca1b9f --- /dev/null +++ b/apps/web/tests/catalogue-changelog-timeline.test.tsx @@ -0,0 +1,162 @@ +import { render, screen, within } from "@testing-library/react"; +import { expect, test } from "vitest"; + +import type { + CatalogueChangelog, + ChangelogEntryView, +} from "@/lib/catalogue/changelog"; +import { ChangelogTimeline } from "@/ui/admin/catalogue/changelog/changelog-timeline"; + +// Local dates, so the day grouping under test does not depend on the runner's +// time zone. +const TODAY = new Date(2026, 8, 22, 12, 0); +const TODAY_AT = new Date(2026, 8, 22, 10, 42).toISOString(); +const TODAY_STARTED_AT = new Date(2026, 8, 22, 10, 38).toISOString(); +const YESTERDAY_AT = new Date(2026, 8, 21, 15, 10).toISOString(); +const PATH = "/admin/courses/2027/comp2700"; + +function entry( + overrides: Partial = {}, +): ChangelogEntryView { + return { + id: "event-1", + kind: "edit", + at: TODAY_AT, + startedAt: TODAY_STARTED_AT, + actorId: "11111111-1111-4111-8111-111111111111", + origin: "manual", + eventIds: [5, 4, 3, 2, 1], + versionId: null, + fields: [ + { + fieldPath: "course.details.description", + label: "Description", + oldValue: "Original", + newValue: "Final", + }, + ], + usedFromSource: [], + keptLocal: [], + actorName: "Harry", + versionOrdinal: null, + sourceChanges: null, + ...overrides, + }; +} + +function renderTimeline( + changelog: Partial, + ordinals = new Map(), +) { + return render( + , + ); +} + +test("an empty changelog says what will fill it", () => { + renderTimeline({}); + expect(screen.getByText("Nothing has happened yet")).toBeTruthy(); +}); + +test("an editing session reads as one entry with its autosave count", () => { + renderTimeline({ entries: [entry()], shown: 1 }); + expect(screen.getByText("Harry edited Description")).toBeTruthy(); + expect(screen.getByText("5 autosaves over 4 minutes")).toBeTruthy(); + const details = screen.getByText("View change").closest("details"); + expect(details?.open).toBe(false); + expect(within(details!).getByText("Original")).toBeTruthy(); + expect(within(details!).getByText("Final")).toBeTruthy(); +}); + +test("entries are grouped under the day they happened", () => { + renderTimeline({ + entries: [ + entry(), + entry({ + id: "event-2", + at: YESTERDAY_AT, + startedAt: YESTERDAY_AT, + kind: "publish", + eventIds: [2], + versionId: 14, + versionOrdinal: 14, + fields: [], + }), + ], + shown: 2, + }); + expect(screen.getByText("Today")).toBeTruthy(); + expect(screen.getByText("Yesterday")).toBeTruthy(); + expect(screen.getByText("Published version 14")).toBeTruthy(); +}); + +test("a version entry links to the version rather than printing a row id", () => { + renderTimeline( + { + entries: [ + entry({ + kind: "publish", + versionId: 87, + versionOrdinal: 3, + fields: [], + eventIds: [9], + }), + ], + shown: 1, + }, + new Map([[87, 3]]), + ); + const link = screen.getByRole("link", { name: "View version 3" }); + expect(link.getAttribute("href")).toBe(`${PATH}/changelog/3`); + expect(screen.queryByText(/87/)).toBeNull(); +}); + +test("a review entry names what was used and what was kept", () => { + renderTimeline({ + entries: [ + entry({ + kind: "source_accepted", + origin: "source", + actorName: "Harry", + eventIds: [7, 6], + fields: [], + usedFromSource: ["Offerings", "Learning outcomes"], + keptLocal: ["Description"], + }), + ], + shown: 1, + }); + expect(screen.getByText("ANU changes reviewed")).toBeTruthy(); + expect(screen.getByText("Offerings, Learning outcomes")).toBeTruthy(); + expect(screen.getByText("Description")).toBeTruthy(); +}); + +test("a sync entry counts the changes it found", () => { + renderTimeline({ + entries: [ + entry({ + kind: "source_changed", + origin: "source", + actorName: null, + eventIds: [3], + fields: [], + sourceChanges: { total: 3, conflicts: 1 }, + }), + ], + shown: 1, + }); + expect(screen.getByText("ANU changes found")).toBeTruthy(); + expect(screen.getByText("3 changes to review, 1 conflict")).toBeTruthy(); + expect(screen.getByText("ANU sync")).toBeTruthy(); +}); + +test("a long history offers the rest of itself", () => { + renderTimeline({ entries: [entry()], shown: 40, hasMore: true }); + const link = screen.getByRole("link", { name: "Show earlier history" }); + expect(link.getAttribute("href")).toBe(`${PATH}/changelog?events=80`); +}); diff --git a/apps/web/tests/catalogue-changelog.test.ts b/apps/web/tests/catalogue-changelog.test.ts new file mode 100644 index 00000000..2c7e34e7 --- /dev/null +++ b/apps/web/tests/catalogue-changelog.test.ts @@ -0,0 +1,201 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + type ChangelogEvent, + editingSessionSummary, + groupChangelogEvents, +} from "../lib/catalogue/changelog.ts"; + +const SESSION = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const OTHER_SESSION = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; +const HARRY = "11111111-1111-4111-8111-111111111111"; +const SAM = "22222222-2222-4222-8222-222222222222"; + +function event( + overrides: Partial & { id: number }, +): ChangelogEvent { + return { + eventKind: "edit", + origin: "manual", + actorId: HARRY, + editingSessionId: SESSION, + versionId: null, + syncChangeId: null, + createdAt: "2026-09-21T10:00:00.000Z", + fields: [], + decision: null, + ...overrides, + }; +} + +function edit( + id: number, + minute: number, + from: string, + to: string, + extra?: Partial, +) { + return event({ + id, + createdAt: `2026-09-21T10:${String(minute).padStart(2, "0")}:00.000Z`, + fields: [ + { fieldPath: "course.details.description", oldValue: from, newValue: to }, + ], + ...extra, + }); +} + +test("one editing session reads as one entry from its first to its last value", () => { + // Newest first, as the loader supplies them. + const entries = groupChangelogEvents([ + edit(5, 4, "Fourth", "Final"), + edit(4, 3, "Third", "Fourth"), + edit(3, 2, "Second", "Third"), + edit(2, 1, "First", "Second"), + edit(1, 0, "Original", "First"), + ]); + assert.equal(entries.length, 1); + assert.deepEqual(entries[0]!.eventIds, [5, 4, 3, 2, 1]); + assert.deepEqual(entries[0]!.fields, [ + { + fieldPath: "course.details.description", + label: "Description", + oldValue: "Original", + newValue: "Final", + }, + ]); + assert.equal( + editingSessionSummary(entries[0]!), + "5 autosaves over 4 minutes", + ); +}); + +test("a value typed and taken back again is not a change", () => { + const entries = groupChangelogEvents([ + edit(2, 1, "Tried something", "Original"), + edit(1, 0, "Original", "Tried something"), + ]); + assert.deepEqual(entries[0]!.fields, []); +}); + +test("separate sessions and separate people stay separate", () => { + const entries = groupChangelogEvents([ + edit(3, 9, "B", "C", { editingSessionId: OTHER_SESSION }), + edit(2, 5, "A", "B", { actorId: SAM }), + edit(1, 0, "Start", "A"), + ]); + assert.equal(entries.length, 3); + assert.equal(editingSessionSummary(entries[0]!), null); +}); + +test("decisions taken in one sitting read as one review", () => { + const entries = groupChangelogEvents([ + event({ + id: 3, + eventKind: "source_kept", + origin: "source", + editingSessionId: null, + createdAt: "2026-09-21T10:12:00.000Z", + syncChangeId: 12, + decision: { + fieldPath: "course.details.description", + decision: "keep_local", + }, + }), + event({ + id: 2, + eventKind: "source_accepted", + origin: "source", + editingSessionId: null, + createdAt: "2026-09-21T10:10:00.000Z", + syncChangeId: 11, + decision: { + fieldPath: "course.learningOutcomes", + decision: "use_source", + }, + fields: [ + { fieldPath: "course.learningOutcomes", oldValue: [], newValue: [{}] }, + ], + }), + event({ + id: 1, + eventKind: "source_accepted", + origin: "source", + editingSessionId: null, + createdAt: "2026-09-21T09:00:00.000Z", + syncChangeId: 10, + decision: { fieldPath: "course.fees", decision: "use_source" }, + }), + ]); + assert.equal(entries.length, 2); + assert.deepEqual(entries[0]!.usedFromSource, ["Learning outcomes"]); + assert.deepEqual(entries[0]!.keptLocal, ["Description"]); + assert.deepEqual(entries[1]!.usedFromSource, ["Fees"]); +}); + +test("quiet ANU checks collapse instead of filling the timeline", () => { + const entries = groupChangelogEvents([ + event({ + id: 3, + eventKind: "source_checked", + origin: "source", + actorId: null, + editingSessionId: null, + }), + event({ + id: 2, + eventKind: "source_checked", + origin: "source", + actorId: null, + editingSessionId: null, + }), + event({ + id: 1, + eventKind: "source_checked", + origin: "source", + actorId: null, + editingSessionId: null, + }), + ]); + assert.equal(entries.length, 1); + assert.equal(entries[0]!.eventIds.length, 3); +}); + +test("publication and lifecycle events always stand alone", () => { + const entries = groupChangelogEvents([ + event({ + id: 4, + eventKind: "restore", + versionId: 40, + editingSessionId: null, + }), + event({ + id: 3, + eventKind: "discard", + versionId: 30, + editingSessionId: null, + }), + event({ + id: 2, + eventKind: "unpublish", + versionId: 20, + editingSessionId: null, + }), + event({ + id: 1, + eventKind: "publish", + versionId: 10, + editingSessionId: null, + }), + ]); + assert.deepEqual( + entries.map((entry) => [entry.kind, entry.versionId]), + [ + ["restore", 40], + ["discard", 30], + ["unpublish", 20], + ["publish", 10], + ], + ); +}); diff --git a/apps/web/tests/catalogue-routes.test.ts b/apps/web/tests/catalogue-routes.test.ts index ca95ec46..5605be37 100644 --- a/apps/web/tests/catalogue-routes.test.ts +++ b/apps/web/tests/catalogue-routes.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "vitest"; import { adminCatalogueRecordPath, + adminCatalogueVersionPath, adminCatalogueYearPath, publicCatalogueRecordPath, } from "@/lib/coursemap/catalogue-kinds"; @@ -20,3 +21,12 @@ test("catalogue paths put the year before a lowercase human-readable code", () = "/specialisations/2027/data-sci", ); }); + +test("a version is addressed by its place in the record, not by a row id", () => { + expect(adminCatalogueVersionPath("course", 2027, "COMP2700", 3)).toBe( + "/admin/courses/2027/comp2700/changelog/3", + ); + expect(adminCatalogueVersionPath("programme", 2027, "BCOMP", 12)).toBe( + "/admin/programmes/2027/bcomp/changelog/12", + ); +}); diff --git a/apps/web/tests/catalogue-source-review-database.test.mjs b/apps/web/tests/catalogue-source-review-database.test.mjs index 74676e35..38f3d9c8 100644 --- a/apps/web/tests/catalogue-source-review-database.test.mjs +++ b/apps/web/tests/catalogue-source-review-database.test.mjs @@ -225,11 +225,17 @@ test("keeping a local value survives an unchanged source and reopens when ANU mo ); assert.equal(Number(afterKeep.revision), 1); const [keptEvent] = await sql` - select event_kind, origin, actor_id from public.catalogue_change_events + select event_kind, origin, actor_id, sync_change_id + from public.catalogue_change_events where record_id = ${recordId} and event_kind = 'source_kept' `; assert.equal(keptEvent.origin, "source"); assert.equal(keptEvent.actor_id, ADMIN_ID); + assert.equal( + Number(keptEvent.sync_change_id), + opened.conflicts[0].id, + "the changelog can name the field that was kept", + ); // ANU repeats the same wording. The baseline advanced, so this is an // override rather than a question the administrator already answered. @@ -355,11 +361,12 @@ test("using ANU writes one path, keeps unrelated edits and moves only its proven ); const [acceptedEvent] = await sql` - select events.id, events.event_kind, events.draft_revision + select events.id, events.event_kind, events.draft_revision, events.sync_change_id from public.catalogue_change_events as events where events.record_id = ${recordId} and events.event_kind = 'source_accepted' `; assert.equal(Number(acceptedEvent.draft_revision), 3); + assert.equal(Number(acceptedEvent.sync_change_id), unaffected.incoming[0].id); const fieldChanges = await sql` select field_path, new_value from public.catalogue_field_changes where event_id = ${acceptedEvent.id} diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index fa928359..e3909b8e 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -810,6 +810,7 @@ export type Database = { id: number origin: string record_id: number + sync_change_id: number | null version_id: number | null } Insert: { @@ -821,6 +822,7 @@ export type Database = { id?: never origin: string record_id: number + sync_change_id?: number | null version_id?: number | null } Update: { @@ -832,6 +834,7 @@ export type Database = { id?: never origin?: string record_id?: number + sync_change_id?: number | null version_id?: number | null } Relationships: [ @@ -849,6 +852,13 @@ export type Database = { referencedRelation: "published_course_summaries" referencedColumns: ["record_id"] }, + { + foreignKeyName: "catalogue_change_events_sync_change_id_fkey" + columns: ["sync_change_id"] + isOneToOne: false + referencedRelation: "catalogue_sync_changes" + referencedColumns: ["id"] + }, { foreignKeyName: "catalogue_change_events_version_fkey" columns: ["version_id", "record_id"] diff --git a/apps/web/ui/admin/catalogue/catalogue-route-pages.tsx b/apps/web/ui/admin/catalogue/catalogue-route-pages.tsx index 5e3cbe23..58e9b520 100644 --- a/apps/web/ui/admin/catalogue/catalogue-route-pages.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-route-pages.tsx @@ -2,9 +2,14 @@ import { notFound, redirect } from "next/navigation"; import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; import { adminCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds"; import { CatalogueDirectoryPage, type SearchParams } from "./catalogue-pages"; +import { CatalogueVersionPage } from "./changelog/version-page"; import { CatalogueRecordPage } from "./record-page"; import type { RecordSection } from "./record-tabs"; +function first(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; +} + const RECORD_SECTIONS = new Set([ "content", "student-view", @@ -42,11 +47,13 @@ export async function CatalogueRecordRoute({ year, code, section, + searchParams, }: { kind: CatalogueKind; year: string; code: string; section?: string[]; + searchParams: SearchParams; }) { const academicYear = Number(year); if ( @@ -60,15 +67,30 @@ export async function CatalogueRecordRoute({ redirect( section?.length ? `${canonicalPath}/${section.join("/")}` : canonicalPath, ); - if ((section?.length ?? 0) > 1) notFound(); const requested = section?.[0] ?? "content"; if (!RECORD_SECTIONS.has(requested as RecordSection)) notFound(); + const params = await searchParams; + if (requested === "changelog" && section?.length === 2) { + const ordinal = Number(section[1]); + if (!Number.isInteger(ordinal) || ordinal < 1) notFound(); + return ( + + ); + } + if ((section?.length ?? 0) > 1) notFound(); return ( ); } diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx new file mode 100644 index 00000000..191b8e62 --- /dev/null +++ b/apps/web/ui/admin/catalogue/changelog/changelog-entry.tsx @@ -0,0 +1,165 @@ +import { + FileClock, + Pencil, + RefreshCw, + RotateCcw, + Send, + TriangleAlert, + Trash2, + Undo2, +} from "lucide-react"; +import Link from "next/link"; +import { + type ChangelogEntryView, + editingSessionSummary, +} from "@/lib/catalogue/changelog"; +import { FieldChangeList } from "../field-change-list"; + +function formatTime(value: string) { + return new Intl.DateTimeFormat("en-AU", { timeStyle: "short" }).format( + new Date(value), + ); +} + +function EntryIcon({ kind }: { kind: ChangelogEntryView["kind"] }) { + const shared = { + className: "mt-0.5 size-4 shrink-0 text-muted-foreground", + "aria-hidden": true, + } as const; + if (kind === "publish") return ; + if (kind === "unpublish") return ; + if (kind === "edit") return ; + if (kind === "discard") return ; + if (kind === "restore") return ; + if (kind === "sync_failed") return ; + if (kind === "source_checked" || kind === "source_changed") + return ; + return ; +} + +function editTitle(entry: ChangelogEntryView, actor: string) { + if (entry.fields.length === 1) + return `${actor} edited ${entry.fields[0]!.label}`; + if (entry.fields.length === 0) return `${actor} edited the draft`; + return `${actor} edited ${entry.fields.length} fields`; +} + +function entryTitle(entry: ChangelogEntryView) { + const actor = entry.actorName ?? "Someone"; + const version = entry.versionOrdinal + ? `version ${entry.versionOrdinal}` + : "a version"; + switch (entry.kind) { + case "edit": + return editTitle(entry, actor); + case "publish": + return `Published ${version}`; + case "unpublish": + return "Unpublished"; + case "discard": + return "Draft discarded"; + case "restore": + return `Restored ${version} as a draft`; + case "source_draft_created": + return "ANU content started the draft"; + case "source_checked": + return "Checked ANU"; + case "source_changed": + return "ANU changes found"; + case "sync_failed": + return "ANU sync failed"; + default: + return "ANU changes reviewed"; + } +} + +function entryDetail(entry: ChangelogEntryView) { + if (entry.kind === "edit") return editingSessionSummary(entry); + if (entry.kind === "source_checked") { + return entry.eventIds.length > 1 + ? `Checked ${entry.eventIds.length} times. No changes found.` + : "No changes found."; + } + if (entry.kind === "source_changed" && entry.sourceChanges) { + const { total, conflicts } = entry.sourceChanges; + const changes = `${total} change${total === 1 ? "" : "s"} to review`; + return conflicts > 0 + ? `${changes}, ${conflicts} conflict${conflicts === 1 ? "" : "s"}` + : changes; + } + if (entry.kind === "discard") + return entry.versionOrdinal + ? `Kept as version ${entry.versionOrdinal}, which can be restored.` + : null; + return null; +} + +/** + * One thing that happened, in the vocabulary an administrator works in. The + * raw events behind an entry stay in the database; this names what they add up + * to. + */ +export function ChangelogEntry({ + entry, + versionHref, +}: { + entry: ChangelogEntryView; + versionHref: string | null; +}) { + const detail = entryDetail(entry); + const actor = + entry.kind === "edit" + ? null + : (entry.actorName ?? (entry.origin === "source" ? "ANU sync" : null)); + return ( +
  • + +
    +
    +

    {entryTitle(entry)}

    + +
    + {actor ? ( +

    {actor}

    + ) : null} + {detail ? ( +

    {detail}

    + ) : null} + {entry.usedFromSource.length > 0 ? ( +

    + Used ANU: + {entry.usedFromSource.join(", ")} +

    + ) : null} + {entry.keptLocal.length > 0 ? ( +

    + Kept current: + {entry.keptLocal.join(", ")} +

    + ) : null} + {entry.fields.length > 0 ? ( +
    + + {entry.fields.length === 1 + ? "View change" + : `View ${entry.fields.length} changes`} + +
    + +
    +
    + ) : null} + {versionHref ? ( + + View version {entry.versionOrdinal} + + ) : null} +
    +
  • + ); +} diff --git a/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx new file mode 100644 index 00000000..d0d1bcab --- /dev/null +++ b/apps/web/ui/admin/catalogue/changelog/changelog-timeline.tsx @@ -0,0 +1,94 @@ +import Link from "next/link"; +import { + CHANGELOG_PAGE_SIZE, + type CatalogueChangelog, +} from "@/lib/catalogue/changelog"; +import { CatalogueEmpty } from "@/ui/admin/catalogue-table/catalogue-empty"; +import { ChangelogEntry } from "./changelog-entry"; + +function dayKey(value: string) { + return new Date(value).toDateString(); +} + +function dayLabel(value: string, today: Date) { + const day = new Date(value); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + if (day.toDateString() === today.toDateString()) return "Today"; + if (day.toDateString() === yesterday.toDateString()) return "Yesterday"; + return new Intl.DateTimeFormat("en-AU", { dateStyle: "long" }).format(day); +} + +/** + * The record's history as one story, newest first, grouped by day. Versions + * that can be opened link out to their own page rather than being rendered as + * a database row here. + */ +export function ChangelogTimeline({ + changelog, + path, + versionOrdinals, + today = new Date(), +}: { + changelog: CatalogueChangelog; + path: string; + versionOrdinals: ReadonlyMap; + today?: Date; +}) { + if (changelog.entries.length === 0) { + return ( + + ); + } + + const days: Array<{ + key: string; + label: string; + entries: typeof changelog.entries; + }> = []; + for (const entry of changelog.entries) { + const key = dayKey(entry.at); + const current = days[days.length - 1]; + if (current?.key === key) current.entries.push(entry); + else days.push({ key, label: dayLabel(entry.at, today), entries: [entry] }); + } + + return ( +
    + {days.map((day) => ( +
    +

    + {day.label} +

    +
      + {day.entries.map((entry) => ( + + ))} +
    +
    + ))} + {changelog.hasMore ? ( + + Show earlier history + + ) : null} +
    + ); +} diff --git a/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx b/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx new file mode 100644 index 00000000..1a0fb1e5 --- /dev/null +++ b/apps/web/ui/admin/catalogue/changelog/restore-version-button.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Button } from "@coursemap/ui/primitives/button"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { restoreCatalogueVersionAction } from "@/lib/coursemap/admin-catalogue-actions"; +import { ConfirmDialog } from "@/ui/common/confirm-dialog"; + +/** + * Restores historical content as the working draft. The version itself never + * changes, which is why this is not called a revert, and a draft it would + * replace becomes a version of its own first. + */ +export function RestoreVersionButton({ + recordId, + versionId, + draftRevision, + path, + label = "Restore as draft", +}: { + recordId: number; + versionId: number; + /** The current draft's revision, or null when the record has no draft. */ + draftRevision: number | null; + path: string; + label?: string; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [open, setOpen] = useState(false); + + function restore() { + startTransition(async () => { + const result = await restoreCatalogueVersionAction({ + recordId, + versionId, + expectedRevision: draftRevision, + replaceExistingDraft: draftRevision !== null, + editingSessionId: crypto.randomUUID(), + path, + }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success(result.message ?? "Version restored as a draft."); + setOpen(false); + router.push(path); + router.refresh(); + }); + } + + if (draftRevision === null) { + return ( + + ); + } + + return ( + + {label} + + } + /> + ); +} diff --git a/apps/web/ui/admin/catalogue/changelog/version-page.tsx b/apps/web/ui/admin/catalogue/changelog/version-page.tsx new file mode 100644 index 00000000..b4ce3db0 --- /dev/null +++ b/apps/web/ui/admin/catalogue/changelog/version-page.tsx @@ -0,0 +1,220 @@ +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Badge } from "@coursemap/ui/components/badge"; +import { Button } from "@coursemap/ui/primitives/button"; +import { + canManageCatalogueSources, + canWriteCatalogue, +} from "@/lib/auth/viewer"; +import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; +import { loadCatalogueDraft } from "@/lib/catalogue/drafts"; +import { + loadCatalogueRecord, + loadVersionCoursePreview, + loadVersionWrite, +} from "@/lib/coursemap/admin-catalogue-record"; +import { + CATALOGUE_KIND_LABELS, + type CatalogueKind, + adminCatalogueRecordPath, + fieldLabel, +} from "@/lib/coursemap/catalogue-kinds"; +import { AccessDeniedError } from "@/ui/errors/access-denied-error"; +import { AppShell } from "@/ui/shell"; +import { FieldChangeList } from "../field-change-list"; +import { CoursePreview, StructurePreview } from "../version-preview"; +import { RestoreVersionButton } from "./restore-version-button"; + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en-AU", { dateStyle: "long" }).format( + new Date(value), + ); +} + +/** + * One immutable version, as content rather than as a database row. Comparison + * targets are addressed in the URL so a specific comparison can be linked to. + */ +export async function CatalogueVersionPage({ + kind, + code, + academicYear, + versionOrdinal, + compare, +}: { + kind: CatalogueKind; + code: string; + academicYear: number; + versionOrdinal: number; + compare: string | null; +}) { + const [canManageImports, canWrite] = await Promise.all([ + canManageCatalogueSources(), + canWriteCatalogue(), + ]); + if (!canManageImports && !canWrite) return ; + const record = await loadCatalogueRecord({ kind, code, academicYear }); + if (!record) notFound(); + + const ordered = [...record.versions].sort( + (left, right) => left.id - right.id, + ); + const version = ordered[versionOrdinal - 1]; + if (!version) notFound(); + const labels = CATALOGUE_KIND_LABELS[kind]; + const path = adminCatalogueRecordPath(kind, academicYear, record.code); + const changelogPath = `${path}/changelog`; + const versionPath = `${changelogPath}/${versionOrdinal}`; + + const publication = record.publications.find( + (entry) => entry.versionId === version.id, + ); + const [content, coursePreview, draft] = await Promise.all([ + loadVersionWrite(version.id), + kind === "course" ? loadVersionCoursePreview(version.id) : null, + loadCatalogueDraft(record.recordId), + ]); + if (!content) notFound(); + + const comparisons = [ + ...(draft ? [{ key: "draft", label: "Current draft" }] : []), + ...(record.publishedVersionId + ? [{ key: "published", label: "Published" }] + : []), + ...(versionOrdinal > 1 + ? [ + { + key: String(versionOrdinal - 1), + label: `Version ${versionOrdinal - 1}`, + }, + ] + : []), + ].filter((option) => option.key !== String(versionOrdinal)); + + const comparedTo = + compare && comparisons.some((option) => option.key === compare) + ? compare + : null; + const comparedVersionId = + comparedTo === null || comparedTo === "draft" + ? null + : comparedTo === "published" + ? record.publishedVersionId + : (ordered[Number(comparedTo) - 1]?.id ?? null); + const comparedContent = + comparedTo === "draft" + ? (draft?.content ?? null) + : comparedVersionId + ? await loadVersionWrite(comparedVersionId) + : null; + const differences = comparedContent + ? diffSnapshotWrites(content, comparedContent) + : []; + + return ( + +
    + +
    +
    + ); +} diff --git a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx index 6f18a8d6..cb220d0f 100644 --- a/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx +++ b/apps/web/ui/admin/catalogue/changes/unpublished-changes.tsx @@ -1,16 +1,6 @@ import type { SnapshotChange } from "@/lib/catalogue-import/changes"; import { fieldLabel } from "@/lib/coursemap/catalogue-kinds"; - -function describe(value: unknown) { - if (value === null || value === undefined) return "Not set"; - if (Array.isArray(value)) - return `${value.length} item${value.length === 1 ? "" : "s"}`; - if (typeof value === "object") return "A value"; - if (typeof value === "boolean") return value ? "Yes" : "No"; - const text = String(value).trim(); - if (text === "") return "Not set"; - return text.length > 80 ? `${text.slice(0, 77)}…` : text; -} +import { FieldChangeList } from "../field-change-list"; /** Saved draft work that students will not see until the record is published. */ export function UnpublishedChanges({ @@ -29,23 +19,13 @@ export function UnpublishedChanges({ ); } return ( -
      - {changes.map((change) => ( -
    • -

      {fieldLabel(change.fieldPath)}

      -

      - {describe(change.oldValue)} - - becomes - - {describe(change.newValue)} - -

      -
    • - ))} -
    + ({ + fieldPath: change.fieldPath, + label: fieldLabel(change.fieldPath), + oldValue: change.oldValue, + newValue: change.newValue, + }))} + /> ); } diff --git a/apps/web/ui/admin/catalogue/field-change-list.tsx b/apps/web/ui/admin/catalogue/field-change-list.tsx new file mode 100644 index 00000000..16ae82af --- /dev/null +++ b/apps/web/ui/admin/catalogue/field-change-list.tsx @@ -0,0 +1,54 @@ +export type FieldChangeItem = { + fieldPath: string; + label: string; + oldValue: unknown; + newValue: unknown; +}; + +/** A short, readable stand-in for a stored value of any shape. */ +export function describeFieldValue(value: unknown) { + if (value === null || value === undefined) return "Not set"; + if (Array.isArray(value)) + return `${value.length} item${value.length === 1 ? "" : "s"}`; + if (typeof value === "object") return "A value"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + const text = String(value).trim(); + if (text === "") return "Not set"; + return text.length > 120 ? `${text.slice(0, 117)}…` : text; +} + +/** What changed, one field to a line, oldest value first. */ +export function FieldChangeList({ + changes, + bordered = true, +}: { + changes: readonly FieldChangeItem[]; + bordered?: boolean; +}) { + return ( +
      + {changes.map((change) => ( +
    • +

      {change.label}

      +

      + + {describeFieldValue(change.oldValue)} + + + becomes + + {describeFieldValue(change.newValue)} + +

      +
    • + ))} +
    + ); +} diff --git a/apps/web/ui/admin/catalogue/record-history.tsx b/apps/web/ui/admin/catalogue/record-history.tsx deleted file mode 100644 index 16a94cbe..00000000 --- a/apps/web/ui/admin/catalogue/record-history.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Badge } from "@coursemap/ui/components/badge"; -import { - FileClock, - Pencil, - RotateCcw, - Send, - Trash2, - Undo2, -} from "lucide-react"; -import type { CatalogueRecord } from "@/lib/coursemap/admin-catalogue-record"; - -function formatDateTime(value: string) { - return new Intl.DateTimeFormat("en-AU", { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(value)); -} - -export function RecordHistory({ record }: { record: CatalogueRecord }) { - const events = [ - ...record.versions.map((version) => ({ - id: `version-${version.id}`, - at: version.createdAt, - kind: "version" as const, - title: `Version ${version.id} created`, - detail: - version.origin === "source" ? "Synced from ANU" : "Created manually", - })), - ...record.publications.flatMap((publication, index) => [ - { - id: `published-${index}`, - at: publication.publishedAt, - kind: "published" as const, - title: `Version ${publication.versionId} published`, - detail: null, - }, - ...(publication.unpublishedAt - ? [ - { - id: `unpublished-${index}`, - at: publication.unpublishedAt, - kind: "unpublished" as const, - title: `Version ${publication.versionId} unpublished`, - detail: null, - }, - ] - : []), - ]), - ...record.changeEvents - .filter( - (event) => - event.eventKind !== "publish" && event.eventKind !== "unpublish", - ) - .map((event) => ({ - id: `change-${event.id}`, - at: event.createdAt, - kind: event.eventKind, - title: - event.eventKind === "edit" - ? "Draft updated" - : event.eventKind === "discard" - ? "Draft discarded" - : event.eventKind === "restore" - ? "Version restored as draft" - : event.eventKind === "source_draft_created" - ? "ANU source populated the draft" - : event.eventKind === "source_checked" - ? "ANU source checked" - : event.eventKind === "source_changed" - ? "ANU source changes detected" - : "ANU sync failed", - detail: - event.eventKind === "discard" && event.versionId - ? `Restorable checkpoint version ${event.versionId}` - : event.eventKind === "restore" && event.versionId - ? `Restored from version ${event.versionId}` - : null, - })), - ].sort((left, right) => Date.parse(right.at) - Date.parse(left.at)); - - if (events.length === 0) - return ( -
    -

    No changelog entries yet

    -

    - Versions and publication activity will appear here. -

    -
    - ); - - return ( -
      - {events.map((event) => { - const Icon = - event.kind === "published" - ? Send - : event.kind === "unpublished" - ? Undo2 - : event.kind === "edit" - ? Pencil - : event.kind === "discard" - ? Trash2 - : event.kind === "restore" - ? RotateCcw - : FileClock; - return ( -
    1. -
    2. - ); - })} -
    - ); -} diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index d77d01ed..149e2fa4 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -12,6 +12,8 @@ import { import { diffSnapshotWrites } from "@/lib/catalogue-import/changes"; import { contentHashForCatalogueContent } from "@/lib/catalogue-import/version-content"; import { loadSourceReview } from "@/lib/catalogue/source-review-store"; +import { CHANGELOG_PAGE_SIZE } from "@/lib/catalogue/changelog"; +import { loadCatalogueChangelog } from "@/lib/coursemap/admin-catalogue-changelog"; import { loadCatalogueRecord, loadVersionCoursePreview, @@ -25,8 +27,8 @@ import { import { AccessDeniedError } from "@/ui/errors/access-denied-error"; import { AppShell } from "@/ui/shell"; import { CatalogueChangesPanel } from "./changes/changes-panel"; +import { ChangelogTimeline } from "./changelog/changelog-timeline"; import { RecordHeader } from "./record-header"; -import { RecordHistory } from "./record-history"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; import { CatalogueContentEditor } from "./content-editor"; import { CoursePreview, StructurePreview } from "./version-preview"; @@ -53,11 +55,14 @@ export async function CatalogueRecordPage({ code, academicYear, section = "content", + changelogEvents = CHANGELOG_PAGE_SIZE, }: { kind: CatalogueKind; code: string; academicYear: number; section?: RecordSection; + /** How many raw audit events the changelog reads before paging. */ + changelogEvents?: number; }) { const [canManageImports, canWrite] = await Promise.all([ canManageCatalogueSources(), @@ -103,6 +108,15 @@ export async function CatalogueRecordPage({ const unpublished = draft ? diffSnapshotWrites(studentContent, draft.content) : []; + const changelog = await loadCatalogueChangelog({ + recordId: record.recordId, + limit: Math.min(Math.max(changelogEvents, CHANGELOG_PAGE_SIZE), 400), + }); + const versionOrdinals = new Map( + [...record.versions] + .sort((left, right) => left.id - right.id) + .map((version, index) => [version.id, index + 1]), + ); const openChanges = (review?.conflicts.length ?? 0) + (review?.incoming.length ?? 0); @@ -171,7 +185,11 @@ export async function CatalogueRecordPage({ /> - + diff --git a/docs/architecture.md b/docs/architecture.md index d6410d04..9e3fac17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,8 +46,9 @@ and version model. These concepts are deliberately separate: A manual edit replaces provenance only for its semantic changed paths. - `catalogue_change_events` and `catalogue_field_changes`: append-only accepted editing, publication, discard and restore operations with exact old and new - values. `editing_session_id` lets the changelog group autosaves later without - rewriting raw history. + values. `editing_session_id` groups autosaves in the changelog without + rewriting raw history, and `sync_change_id` names the ANU review row a source + decision answered - `catalogue_publications`: historical visibility intervals recording the version, publisher, publication time, unpublisher and unpublication time. - `course_version_details` and `structure_version_details` hold the scalar diff --git a/docs/catalogue-completion-plan.md b/docs/catalogue-completion-plan.md index 1f93305c..6da3bd37 100644 --- a/docs/catalogue-completion-plan.md +++ b/docs/catalogue-completion-plan.md @@ -35,23 +35,24 @@ Neither needs a second implementation. ## Sequence and ownership -| Order | Branch | Base | Owner | Depends on | -| ----- | ---------------------------------------- | ---- | ------ | ----------------------- | -| 05 | `feat/catalogue-source-review` | 04 | Claude | 04 | -| 06 | `feat/catalogue-changelog` | 05 | Claude | 05 decision events | -| 07 | `feat/catalogue-student-view` | 04 | agent | none beyond 04 | -| 08 | `feat/catalogue-sync-operations` | 04 | agent | none beyond 04 | -| 09 | `feat/catalogue-automation-and-baseline` | 08 | agent | all of the above merged | +| Order | Branch | Base | Owner | Depends on | +| ----- | ---------------------------------------- | ---- | ------ | ---------- | +| 05 | `feat/catalogue-source-review` | 04 | Claude | landed | +| 06 | `feat/catalogue-changelog` | 05 | Claude | landed | +| 07 | `feat/catalogue-student-view` | 06 | Claude | 06 | +| 08 | `feat/catalogue-sync-operations` | 07 | Claude | 07 | +| 09 | `feat/catalogue-automation-and-baseline` | 08 | agent | 08 | -Branches 07 and 08 touch almost nothing that 05 and 06 touch, so they run in -parallel from 04. Branch 09 is the finalisation phase and starts only when -05 to 08 are merged into the stack. Split it into `09A feat/catalogue-automation` -and `09B refactor/catalogue-schema-baseline` if it grows past a reviewable size. +The stack is strictly sequential: every branch sits on the one before it and +its pull request targets that branch until it merges. Branch 09 is the +finalisation phase. Split it into `09A feat/catalogue-automation` and +`09B refactor/catalogue-schema-baseline` if it grows past a reviewable size. -### Working in parallel +### Working the stack - Branch from the base named above, never from `main`. Rebase forward when a - base branch changes. Use a separate worktree per concurrent branch. + base branch changes. Work in a separate worktree when another branch in the + stack is still open. - Reserved migration timestamp prefixes, so two branches never collide on file order: 05 uses `20260923*`, 06 uses `20260924*`, 07 uses `20260925*`, 08 uses `20260926*`, 09 uses `20260927*` and the baseline rewrite. @@ -290,6 +291,12 @@ Turn `catalogue_change_events`, `catalogue_field_changes`, `catalogue_versions`, `catalogue_publications`, `catalogue_syncs` and `catalogue_sync_changes` into one human timeline. An administrator never learns which table produced a row. +Two schema notes from what landed: `catalogue_change_events.sync_change_id` +names the review row a source decision answered, because keeping a value +changes no content and so leaves no field change row to carry that meaning; and +`restoreCatalogueVersion` now materialises the draft it replaces as a version +first, which is what makes the replacement warning honest. + ## Grouping Branch 03 records `editing_session_id`. Use it. Five autosaves become one diff --git a/docs/catalogue-operations.md b/docs/catalogue-operations.md index 200f1b82..9a3063a5 100644 --- a/docs/catalogue-operations.md +++ b/docs/catalogue-operations.md @@ -81,9 +81,29 @@ using the student-facing view. **Publish draft** materialises and seals a new manual version, advances the publication pointer and clears the draft. **Unpublish** closes the visibility interval without deleting history. -The **Changelog** tab lists immutable versions and publication events. -**Restore as draft** copies historical content into a new draft. **Discard** -clears the draft without deleting versions. +## Read the changelog + +The **Changelog** tab is one timeline of everything that happened to the +record, newest first and grouped by day. It speaks in editing, review and +publication terms; technical execution detail belongs to operations, not here. + +Autosaves are grouped by editing session, so one sitting reads as "Harry edited +Description, 5 autosaves over 4 minutes" with the value it started from and the +value it ended on. A field typed and taken back within a session is not a +change. Repeated quiet ANU checks collapse into one line. The grouping is +presentation only: every raw event keeps its own row, and expanding an entry +shows the fields it covers. + +Versions are numbered within their record and open as a page, not a database +row. A version page renders the student view of that content and offers +**Compare** against the current draft, the published version or the preceding +version, and **Restore as draft**. + +**Restore as draft** copies historical content into the working draft. It is +not called a revert because the version itself never changes. If a draft would +be replaced, the draft is kept as a version of its own first and offered back +from the changelog, so nothing is lost. **Discard** works the same way: it +clears the draft and keeps a restorable checkpoint. ## Reliability and evidence diff --git a/supabase/migrations/20260924100000_catalogue_changelog.sql b/supabase/migrations/20260924100000_catalogue_changelog.sql new file mode 100644 index 00000000..c1ded440 --- /dev/null +++ b/supabase/migrations/20260924100000_catalogue_changelog.sql @@ -0,0 +1,22 @@ +begin; + +-- A source decision event names the review row it answered, so the changelog +-- can say which field was used or kept without matching events to review rows +-- by timestamp. Keeping current changes no content, so there is no field +-- change row to carry that meaning. +alter table public.catalogue_change_events + add column sync_change_id bigint + references public.catalogue_sync_changes (id) on delete set null, + add constraint catalogue_change_events_sync_change_check check ( + sync_change_id is null + or event_kind in ('source_accepted', 'source_kept') + ); + +create index catalogue_change_events_sync_change_idx + on public.catalogue_change_events (sync_change_id) + where sync_change_id is not null; + +comment on column public.catalogue_change_events.sync_change_id is + 'The ANU review row this event decided, for source_accepted and source_kept events.'; + +commit; diff --git a/supabase/tests/database/catalogue_sync_changes.sql b/supabase/tests/database/catalogue_sync_changes.sql index cbdde7ae..e6266d9a 100644 --- a/supabase/tests/database/catalogue_sync_changes.sql +++ b/supabase/tests/database/catalogue_sync_changes.sql @@ -6,7 +6,7 @@ begin; create extension if not exists pgtap with schema extensions; -select extensions.plan(9); +select extensions.plan(11); select extensions.has_table('public', 'catalogue_sync_changes', 'ANU review rows are stored'); @@ -99,11 +99,37 @@ select extensions.throws_ok( select extensions.lives_ok( $$ - insert into public.catalogue_change_events (record_id, event_kind, origin) - select record_id, 'source_accepted', 'source' from public.catalogue_syncs - where id = '33000000-0000-4000-8000-000000000001' + insert into public.catalogue_change_events ( + record_id, event_kind, origin, sync_change_id + ) + select changes.record_id, 'source_accepted', 'source', changes.id + from public.catalogue_sync_changes as changes + where changes.sync_id = '33000000-0000-4000-8000-000000000001' + $$, + 'accepting an ANU value is an auditable event that names the row it answered' +); + +select extensions.throws_ok( + $$ + insert into public.catalogue_change_events ( + record_id, event_kind, origin, sync_change_id + ) + select changes.record_id, 'edit', 'manual', changes.id + from public.catalogue_sync_changes as changes + where changes.sync_id = '33000000-0000-4000-8000-000000000001' $$, - 'accepting an ANU value is an auditable event kind' + '23514', + null, + 'only a source decision may name a review row' +); + +select extensions.is( + ( + select count(*)::int from public.catalogue_change_events + where event_kind = 'source_accepted' and sync_change_id is not null + ), + 1, + 'the changelog can join a decision to the field it decided' ); select * from extensions.finish();