diff --git a/apps/web/app/api/admin/catalogue-directory/route.ts b/apps/web/app/api/admin/catalogue-directory/route.ts index 4d4ddaca..33b9923f 100644 --- a/apps/web/app/api/admin/catalogue-directory/route.ts +++ b/apps/web/app/api/admin/catalogue-directory/route.ts @@ -1,6 +1,6 @@ import { canManageCourseImports } from "@/lib/auth/viewer"; import { refreshCatalogueDirectory } from "@/lib/catalogue-import/directory"; -import { isCatalogueKind } from "@/lib/catalogue-import/snapshot-write"; +import { isCatalogueKind } from "@/lib/catalogue/content"; export const runtime = "nodejs"; export const maxDuration = 60; diff --git a/apps/web/app/api/admin/catalogue-imports/route.ts b/apps/web/app/api/admin/catalogue-imports/route.ts index 4dca0a91..d6b3cb81 100644 --- a/apps/web/app/api/admin/catalogue-imports/route.ts +++ b/apps/web/app/api/admin/catalogue-imports/route.ts @@ -5,7 +5,7 @@ import { processImportRunInline, } from "@/lib/catalogue-import/queue"; import { adapterForKind } from "@/lib/catalogue-import/process-target"; -import { isCatalogueKind } from "@/lib/catalogue-import/snapshot-write"; +import { isCatalogueKind } from "@/lib/catalogue/content"; import { loadImportModelSetting } from "@/lib/admin/settings"; import { createClient } from "@/lib/supabase/server"; diff --git a/apps/web/lib/admin/users.ts b/apps/web/lib/admin/users.ts index 8bc48e9e..abf8ae3c 100644 --- a/apps/web/lib/admin/users.ts +++ b/apps/web/lib/admin/users.ts @@ -105,8 +105,7 @@ export type AdminRoleManagementData = { type AdminPlanItemRow = { academic_period_id: number | null; - academic_year_id: number; - course_id: number; + catalogue_record_id: number; created_at: string; id: string; planned_calendar_year: number | null; @@ -117,8 +116,7 @@ type AdminPlanItemRow = { type AdminCourseAttemptRow = { academic_period_id: number; - course_id: number; - course_snapshot_id: number; + catalogue_version_id: number; created_at: string; id: string; mark: number | null; @@ -393,20 +391,20 @@ export async function loadAdminUserDetail( .maybeSingle(), supabase .from("plan_structures") - .select("role,structure_year_id,position") + .select("role,catalogue_record_id,position") .eq("plan_id", plan.id) .order("position"), supabase .from("plan_items") .select( - "id,course_id,academic_year_id,academic_period_id,planned_calendar_year,planned_period_code,created_at,updated_at,sort_order", + "id,catalogue_record_id,academic_period_id,planned_calendar_year,planned_period_code,created_at,updated_at,sort_order", ) .eq("plan_id", plan.id) .order("sort_order"), supabase .from("course_attempts") .select( - "id,course_id,course_snapshot_id,academic_period_id,status,mark,units_attempted,units_earned,created_at,updated_at", + "id,catalogue_version_id,academic_period_id,status,mark,units_attempted,units_earned,created_at,updated_at", ) .eq("owner_id", userId) .order("created_at"), @@ -425,15 +423,9 @@ export async function loadAdminUserDetail( const itemRows = (itemsResult.data ?? []) as unknown as AdminPlanItemRow[]; const attemptRows = (attemptsResult.data ?? []) as unknown as AdminCourseAttemptRow[]; - const structureYearIds = structureRows.map( - (structure) => structure.structure_year_id, + const attemptVersionIds = attemptRows.map( + (attempt) => attempt.catalogue_version_id, ); - const courseIds = [ - ...new Set([ - ...itemRows.map((item) => item.course_id), - ...attemptRows.map((attempt) => attempt.course_id), - ]), - ]; const periodIds = [ ...new Set([ ...itemRows.flatMap((item) => @@ -443,27 +435,12 @@ export async function loadAdminUserDetail( ]), ]; - const [ - structureYearsResult, - courseIdentitiesResult, - courseYearsResult, - periodsResult, - ] = await Promise.all([ - structureYearIds.length - ? supabase - .from("catalogue_item_years") - .select("id,published_snapshot_id,item_id") - .in("id", structureYearIds) - : Promise.resolve({ data: [], error: null }), - courseIds.length - ? supabase.from("catalogue_items").select("id,code").in("id", courseIds) - : Promise.resolve({ data: [], error: null }), - courseIds.length + const [attemptVersionsResult, periodsResult] = await Promise.all([ + attemptVersionIds.length ? supabase - .from("catalogue_item_years") - .select("item_id,academic_year_id,published_snapshot_id") - .is("archived_at", null) - .in("item_id", courseIds) + .from("catalogue_versions") + .select("id,record_id") + .in("id", attemptVersionIds) : Promise.resolve({ data: [], error: null }), periodIds.length ? supabase @@ -473,55 +450,70 @@ export async function loadAdminUserDetail( : Promise.resolve({ data: [], error: null }), ]); - const relatedError = - structureYearsResult.error ?? - courseIdentitiesResult.error ?? - courseYearsResult.error ?? - periodsResult.error; + const relatedError = attemptVersionsResult.error ?? periodsResult.error; if (relatedError) { throw new Error("Coursemap could not load that user's study details."); } - const structureYears = structureYearsResult.data ?? []; - const structureIdentityIds = structureYears.map( - (structureYear) => structureYear.item_id, + const attemptRecordIdByVersionId = new Map( + (attemptVersionsResult.data ?? []).map((version) => [ + version.id, + version.record_id, + ]), ); - const structureIdentitiesResult = structureIdentityIds.length + const recordIds = [ + ...new Set([ + ...structureRows.map((structure) => structure.catalogue_record_id), + ...itemRows.map((item) => item.catalogue_record_id), + ...attemptRecordIdByVersionId.values(), + ]), + ]; + const recordsResult = recordIds.length ? await supabase - .from("catalogue_items") - .select("id,code") - .in("id", structureIdentityIds) + .from("catalogue_records") + .select("id,published_version_id,code_id,academic_year_id") + .in("id", recordIds) : { data: [], error: null }; - if (structureIdentitiesResult.error) { + if (recordsResult.error) { throw new Error("Coursemap could not load that user's programme."); } + const records = recordsResult.data ?? []; + const codeIds = [...new Set(records.map((record) => record.code_id))]; + const courseIdentitiesResult = codeIds.length + ? await supabase + .from("catalogue_codes") + .select("id,code") + .in("id", codeIds) + : { data: [], error: null }; + if (courseIdentitiesResult.error) + throw new Error("Coursemap could not load that user's catalogue codes."); const structureYearById = new Map( - structureYears.map((structureYear) => [structureYear.id, structureYear]), + records.map((record) => [record.id, record]), ); const structureCodeById = new Map( - (structureIdentitiesResult.data ?? []).map((identity) => [ + (courseIdentitiesResult.data ?? []).map((identity) => [ identity.id, identity.code, ]), ); - const structureSnapshotIds = structureYears.flatMap((structureYear) => - structureYear.published_snapshot_id === null + const structureSnapshotIds = records.flatMap((structureYear) => + structureYear.published_version_id === null ? [] - : [structureYear.published_snapshot_id], + : [structureYear.published_version_id], ); const structureSnapshotsResult = structureSnapshotIds.length ? await supabase - .from("structure_snapshot_details") - .select("snapshot_id,name,units") - .in("snapshot_id", structureSnapshotIds) + .from("structure_version_details") + .select("version_id,name,units") + .in("version_id", structureSnapshotIds) : { data: [], error: null }; if (structureSnapshotsResult.error) { throw new Error("Coursemap could not load that user's programme."); } const structureSnapshotById = new Map( (structureSnapshotsResult.data ?? []).map((snapshot) => [ - snapshot.snapshot_id, + snapshot.version_id, snapshot, ]), ); @@ -531,36 +523,27 @@ export async function loadAdminUserDetail( course.code, ]), ); - const publishedSnapshotByCourseYear = new Map( - (courseYearsResult.data ?? []).flatMap((courseYear) => - courseYear.published_snapshot_id - ? [ - [ - `${courseYear.item_id}:${courseYear.academic_year_id}`, - courseYear.published_snapshot_id, - ] as const, - ] - : [], - ), - ); + const recordById = new Map(records.map((record) => [record.id, record])); const snapshotIds = [ ...new Set([ - ...publishedSnapshotByCourseYear.values(), - ...attemptRows.map((attempt) => attempt.course_snapshot_id), + ...records.flatMap((record) => + record.published_version_id ? [record.published_version_id] : [], + ), + ...attemptRows.map((attempt) => attempt.catalogue_version_id), ]), ]; const snapshotsResult = snapshotIds.length ? await supabase - .from("course_snapshot_details") - .select("snapshot_id,title,units,minimum_units,maximum_units") - .in("snapshot_id", snapshotIds) + .from("course_version_details") + .select("version_id,title,units,minimum_units,maximum_units") + .in("version_id", snapshotIds) : { data: [], error: null }; if (snapshotsResult.error) { throw new Error("Coursemap could not load that user's course details."); } const snapshotById = new Map( (snapshotsResult.data ?? []).map((snapshot) => [ - snapshot.snapshot_id, + snapshot.version_id, snapshot, ]), ); @@ -569,11 +552,10 @@ export async function loadAdminUserDetail( ); const plannedCourses: AdminUserCourse[] = itemRows.flatMap((item) => { - const code = courseCodeById.get(item.course_id); + const record = recordById.get(item.catalogue_record_id); + const code = record ? courseCodeById.get(record.code_id) : undefined; if (!code) return []; - const publishedSnapshotId = publishedSnapshotByCourseYear.get( - `${item.course_id}:${item.academic_year_id}`, - ); + const publishedSnapshotId = record?.published_version_id; const snapshot = publishedSnapshotId ? snapshotById.get(publishedSnapshotId) : null; @@ -606,8 +588,12 @@ export async function loadAdminUserDetail( }); const recordedCourses: AdminUserCourse[] = attemptRows.flatMap( (attempt) => { - const code = courseCodeById.get(attempt.course_id); - const snapshot = snapshotById.get(attempt.course_snapshot_id); + const recordId = attemptRecordIdByVersionId.get( + attempt.catalogue_version_id, + ); + const record = recordId ? recordById.get(recordId) : undefined; + const code = record ? courseCodeById.get(record.code_id) : undefined; + const snapshot = snapshotById.get(attempt.catalogue_version_id); const period = periodById.get(attempt.academic_period_id); const status = courseStatus(attempt.status); if (!code || !status) return []; @@ -645,13 +631,13 @@ export async function loadAdminUserDetail( }, structures: structureRows.flatMap((structure) => { const structureYear = structureYearById.get( - structure.structure_year_id, + structure.catalogue_record_id, ); - const snapshot = structureYear?.published_snapshot_id - ? structureSnapshotById.get(structureYear.published_snapshot_id) + const snapshot = structureYear?.published_version_id + ? structureSnapshotById.get(structureYear.published_version_id) : null; const code = structureYear - ? structureCodeById.get(structureYear.item_id) + ? structureCodeById.get(structureYear.code_id) : null; return snapshot && code ? [ diff --git a/apps/web/lib/catalogue-import/apply-review.ts b/apps/web/lib/catalogue-import/apply-review.ts index f8634a8f..37185fa5 100644 --- a/apps/web/lib/catalogue-import/apply-review.ts +++ b/apps/web/lib/catalogue-import/apply-review.ts @@ -1,8 +1,11 @@ import { applyAcceptedChanges } from "./changes.ts"; import { type ImportSql, withImportDatabaseClient } from "./import-store.ts"; -import { insertSnapshotContent } from "./persist-snapshot.ts"; -import { contentHashForWrite, readSnapshotWrite } from "./snapshot-read.ts"; -import type { CatalogueKind } from "./snapshot-write.ts"; +import { insertVersionContent } from "./persist-version.ts"; +import { + contentHashForCatalogueContent, + readVersionContent, +} from "./version-content.ts"; +import type { CatalogueKind } from "../catalogue/content.ts"; export class ApplyReviewError extends Error { readonly code: string; @@ -15,9 +18,9 @@ export class ApplyReviewError extends Error { } /** - * Turns a reviewed target into a new draft: the baseline with every accepted + * Turns a reviewed target into a new immutable version: the baseline with every accepted * change applied, rejected changes left as they were. Refuses when changes - * are still open or the item year's draft moved since the import ran. + * are still open or the record gained another meaningful version since the import ran. */ export async function applyImportReview({ targetId, @@ -31,12 +34,26 @@ export async function applyImportReview({ const work = async (client: ImportSql) => client.begin(async (tx) => { const [target] = await tx` - select targets.id, targets.kind, targets.item_year_id, targets.academic_year_id, - targets.baseline_snapshot_id, targets.candidate_snapshot_id, targets.source_page_id, + select targets.id, targets.kind, targets.record_id, targets.academic_year_id, + targets.baseline_version_id, targets.candidate_version_id, targets.source_page_id, targets.status, targets.applied_at, - item_years.draft_snapshot_id, item_years.published_snapshot_id, item_years.archived_at + item_years.archived_at, current_version.id as current_version_id from public.catalogue_import_targets as targets - join public.catalogue_item_years as item_years on item_years.id = targets.item_year_id + join public.catalogue_records as item_years on item_years.id = targets.record_id + left join lateral ( + select versions.id + from public.catalogue_versions as versions + left join public.catalogue_import_targets as version_targets + on version_targets.id = versions.import_target_id + where versions.record_id = item_years.id + and versions.sealed_at is not null + and ( + versions.import_target_id is null + or version_targets.applied_version_id = versions.id + ) + order by versions.created_at desc, versions.id desc + limit 1 + ) as current_version on true where targets.id = ${targetId}::uuid for update of targets, item_years `; @@ -50,7 +67,7 @@ export async function applyImportReview({ "This review has already been applied.", "55000", ); - if (target.status !== "ready" || !target.candidate_snapshot_id) { + if (target.status !== "ready" || !target.candidate_version_id) { throw new ApplyReviewError( "Only targets that are ready for review can be applied.", "55000", @@ -60,18 +77,16 @@ export async function applyImportReview({ throw new ApplyReviewError("The record is archived.", "55000"); const currentBaseline = - target.draft_snapshot_id === null - ? target.published_snapshot_id === null - ? null - : Number(target.published_snapshot_id) - : Number(target.draft_snapshot_id); + target.current_version_id === null + ? null + : Number(target.current_version_id); const importBaseline = - target.baseline_snapshot_id === null + target.baseline_version_id === null ? null - : Number(target.baseline_snapshot_id); + : Number(target.baseline_version_id); if (currentBaseline !== importBaseline) { throw new ApplyReviewError( - "The record changed since this import ran. Start a new import to review against the current draft.", + "The record changed since this import ran. Start a new import to review against the current version.", "STALE_BASELINE", ); } @@ -91,39 +106,36 @@ export async function applyImportReview({ .filter((entry) => entry.status === "accepted") .map((entry) => String(entry.field_path)), ); - const candidateId = Number(target.candidate_snapshot_id); + const candidateId = Number(target.candidate_version_id); const kind = target.kind as CatalogueKind; - const itemYearId = Number(target.item_year_id); + const recordId = Number(target.record_id); - // Everything accepted: the candidate itself becomes the draft. + // Everything accepted: the candidate itself becomes the applied version. if (importBaseline === null || accepted.size === entries.length) { - await tx` - update public.catalogue_item_years set draft_snapshot_id = ${candidateId} where id = ${itemYearId} - `; await tx` update public.catalogue_import_targets - set applied_snapshot_id = ${candidateId}, applied_at = now() + set applied_version_id = ${candidateId}, applied_at = now() where id = ${targetId}::uuid `; - return { draftSnapshotId: candidateId, reusedCandidate: true }; + return { versionId: candidateId, reusedCandidate: true }; } - const baselineWrite = await readSnapshotWrite(tx, importBaseline); - const candidateWrite = await readSnapshotWrite(tx, candidateId); + const baselineWrite = await readVersionContent(tx, importBaseline); + const candidateWrite = await readVersionContent(tx, candidateId); if (!baselineWrite || !candidateWrite) { throw new ApplyReviewError( "The snapshots for this review could not be read.", "P0002", ); } - // Nothing accepted: the baseline stays the draft and the review is closed. + // Nothing accepted: the baseline stays current and the review is closed. if (accepted.size === 0) { await tx` update public.catalogue_import_targets - set applied_snapshot_id = ${importBaseline}, applied_at = now() + set applied_version_id = ${importBaseline}, applied_at = now() where id = ${targetId}::uuid `; - return { draftSnapshotId: importBaseline, reusedCandidate: false }; + return { versionId: importBaseline, reusedCandidate: false }; } const merged = applyAcceptedChanges( @@ -131,19 +143,19 @@ export async function applyImportReview({ candidateWrite, accepted, ); - merged.contentHash = contentHashForWrite(merged); + merged.contentHash = contentHashForCatalogueContent(merged); const [snapshot] = await tx` - insert into public.catalogue_snapshots ( - item_year_id, kind, academic_year_id, origin, based_on_snapshot_id, source_page_id, + insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, based_on_version_id, source_page_id, content_hash, import_target_id, created_by ) values ( - ${itemYearId}, ${kind}, ${Number(target.academic_year_id)}, 'import', ${importBaseline}, + ${recordId}, ${kind}, ${Number(target.academic_year_id)}, 'import', ${importBaseline}, ${target.source_page_id}, ${merged.contentHash}, ${targetId}::uuid, ${userId}::uuid ) returning id `; const snapshotId = Number(snapshot.id); - await insertSnapshotContent(tx, { + await insertVersionContent(tx, { snapshotId, kind, academicYearId: Number(target.academic_year_id), @@ -152,14 +164,16 @@ export async function applyImportReview({ write: merged, }); await tx` - update public.catalogue_item_years set draft_snapshot_id = ${snapshotId} where id = ${itemYearId} + update public.catalogue_versions + set sealed_at = greatest(statement_timestamp(), created_at) + where id = ${snapshotId} `; await tx` update public.catalogue_import_targets - set applied_snapshot_id = ${snapshotId}, applied_at = now() + set applied_version_id = ${snapshotId}, applied_at = now() where id = ${targetId}::uuid `; - return { draftSnapshotId: snapshotId, reusedCandidate: false }; + return { versionId: snapshotId, reusedCandidate: false }; }); return sql ? work(sql) : withImportDatabaseClient(work); } diff --git a/apps/web/lib/catalogue-import/changes.ts b/apps/web/lib/catalogue-import/changes.ts index a53e1375..47f15df5 100644 --- a/apps/web/lib/catalogue-import/changes.ts +++ b/apps/web/lib/catalogue-import/changes.ts @@ -1,11 +1,11 @@ import { stableStringify } from "./canonical.ts"; import { fieldLabel } from "../coursemap/catalogue-kinds.ts"; import type { - CatalogueSnapshotWrite, + CatalogueContent, + CatalogueContentFlag, RequirementRuleKind, RequirementWrite, - SnapshotFlagWrite, -} from "./snapshot-write.ts"; +} from "../catalogue/content.ts"; export type SnapshotChange = { fieldPath: string; @@ -85,8 +85,8 @@ export function requirementRuleSlice( * decision replaces the section rather than merging arrays item by item. */ export function diffSnapshotWrites( - baseline: CatalogueSnapshotWrite | null, - candidate: CatalogueSnapshotWrite, + baseline: CatalogueContent | null, + candidate: CatalogueContent, ): SnapshotChange[] { const changes: SnapshotChange[] = []; const evidenceFor = (fieldPath: string) => { @@ -156,7 +156,7 @@ export function diffSnapshotWrites( } /** Errors block publication; warnings inform. */ -export function isBlockingFlag(flag: SnapshotFlagWrite) { +export function isBlockingFlag(flag: CatalogueContentFlag) { return flag.severity === "error"; } @@ -168,11 +168,11 @@ type Section = Record; * replace the whole rule slice. */ export function applyAcceptedChanges( - baseline: CatalogueSnapshotWrite, - candidate: CatalogueSnapshotWrite, + baseline: CatalogueContent, + candidate: CatalogueContent, acceptedPaths: ReadonlySet, -): CatalogueSnapshotWrite { - const result: CatalogueSnapshotWrite = structuredClone(baseline); +): CatalogueContent { + const result: CatalogueContent = structuredClone(baseline); result.evidence = candidate.evidence; result.flags = candidate.flags; const replacedRules = new Set(); diff --git a/apps/web/lib/catalogue-import/directory.ts b/apps/web/lib/catalogue-import/directory.ts index 743c6b27..c24e90c4 100644 --- a/apps/web/lib/catalogue-import/directory.ts +++ b/apps/web/lib/catalogue-import/directory.ts @@ -7,7 +7,7 @@ import { withImportDatabaseClient, } from "./import-store.ts"; import type { AcademicStructureKind } from "./kinds/structure/contract.ts"; -import { type CatalogueKind, isCatalogueKind } from "./snapshot-write.ts"; +import { type CatalogueKind, isCatalogueKind } from "../catalogue/content.ts"; export type DirectoryRefreshProgress = { phase: "fetching" | "saving" | "done"; @@ -140,11 +140,11 @@ async function saveDirectory( // Entries that already have an identity keep it linked. await tx` update public.catalogue_directory_entries as entries - set item_id = items.id - from public.catalogue_items as items + set code_id = items.id + from public.catalogue_codes as items where entries.academic_year_id = ${academicYearId} and entries.kind = ${kind} - and entries.item_id is null + and entries.code_id is null and items.kind = entries.kind and items.code = entries.code `; diff --git a/apps/web/lib/catalogue-import/import-store.ts b/apps/web/lib/catalogue-import/import-store.ts index e59a7be3..661c9256 100644 --- a/apps/web/lib/catalogue-import/import-store.ts +++ b/apps/web/lib/catalogue-import/import-store.ts @@ -8,7 +8,7 @@ import type { ImportArtifactLocator, } from "./artifact-store.ts"; import { ANU_PROGRAMS_AND_COURSES_SOURCE } from "./import-source.ts"; -import type { CatalogueKind } from "./snapshot-write.ts"; +import type { CatalogueKind } from "../catalogue/content.ts"; export type ImportStageName = | "source_fetch" @@ -83,9 +83,9 @@ export type ClaimedImportTarget = { academicYear: number; academicYearId: number; itemId: number; - itemYearId: number; + recordId: number; directoryEntryId: number | null; - baselineSnapshotId: number | null; + baselineVersionId: number | null; requestedModel: string; parserVersion: string; promptVersion: string; @@ -167,10 +167,10 @@ export async function claimImportTarget( targets.code, academic_years.year as academic_year, targets.academic_year_id, - targets.item_id, - targets.item_year_id, + targets.code_id, + targets.record_id, targets.directory_entry_id, - targets.baseline_snapshot_id, + targets.baseline_version_id, runs.requested_model, runs.parser_version, runs.prompt_version, @@ -188,10 +188,10 @@ export async function claimImportTarget( code: String(row.code), academicYear: Number(row.academic_year), academicYearId: Number(row.academic_year_id), - itemId: Number(row.item_id), - itemYearId: Number(row.item_year_id), + itemId: Number(row.code_id), + recordId: Number(row.record_id), directoryEntryId: numberOrNull(row.directory_entry_id), - baselineSnapshotId: numberOrNull(row.baseline_snapshot_id), + baselineVersionId: numberOrNull(row.baseline_version_id), requestedModel: String(row.requested_model), parserVersion: String(row.parser_version), promptVersion: String(row.prompt_version), @@ -568,7 +568,7 @@ export async function finishImportTarget( status, changeKind, sourcePageId, - candidateSnapshotId, + candidateVersionId, errorCode = null, errorMessage = null, }: { @@ -579,7 +579,7 @@ export async function finishImportTarget( status: "ready" | "unchanged" | "failed"; changeKind: "new" | "changed" | "unchanged" | null; sourcePageId: number | null; - candidateSnapshotId: number | null; + candidateVersionId: number | null; errorCode?: string | null; errorMessage?: string | null; }, @@ -590,7 +590,7 @@ export async function finishImportTarget( set status = ${status}, change_kind = ${changeKind}, source_page_id = ${sourcePageId}, - candidate_snapshot_id = ${candidateSnapshotId}, + candidate_version_id = ${candidateVersionId}, error_code = ${errorCode}, error_message = ${errorMessage}, worker_id = null, diff --git a/apps/web/lib/catalogue-import/kind-adapter.ts b/apps/web/lib/catalogue-import/kind-adapter.ts index 23e0ea89..1099b90c 100644 --- a/apps/web/lib/catalogue-import/kind-adapter.ts +++ b/apps/web/lib/catalogue-import/kind-adapter.ts @@ -1,8 +1,5 @@ import type { ClaimedImportTarget } from "./import-store.ts"; -import type { - CatalogueKind, - CatalogueSnapshotWrite, -} from "./snapshot-write.ts"; +import type { CatalogueKind, CatalogueContent } from "../catalogue/content.ts"; export type FetchedSourcePage = { sourceUrl: string; @@ -86,5 +83,5 @@ export type CatalogueKindAdapter = { /** The provider's stop reason; `length` means the response was truncated. */ finishReason: string | null; }): MergeOutcome; - project(extraction: Extraction): CatalogueSnapshotWrite; + project(extraction: Extraction): CatalogueContent; }; diff --git a/apps/web/lib/catalogue-import/kinds/course/adapter.ts b/apps/web/lib/catalogue-import/kinds/course/adapter.ts index fc1a312f..231178ce 100644 --- a/apps/web/lib/catalogue-import/kinds/course/adapter.ts +++ b/apps/web/lib/catalogue-import/kinds/course/adapter.ts @@ -1,5 +1,5 @@ import type { CatalogueKindAdapter } from "../../kind-adapter.ts"; -import { courseSnapshotWrite } from "../../snapshot-write.ts"; +import { courseCatalogueContent } from "../../../catalogue/content.ts"; import { COURSE_EXTRACTION_JSON_SCHEMA, type CourseExtraction, @@ -130,7 +130,7 @@ export const courseKindAdapter: CatalogueKindAdapter = { }; }, project(extraction) { - return courseSnapshotWrite({ + return courseCatalogueContent({ projection: projectCourseSnapshot(extraction), evidence: extraction.evidence.map((item) => ({ fieldPath: item.fieldKey, diff --git a/apps/web/lib/catalogue-import/kinds/structure/adapter.ts b/apps/web/lib/catalogue-import/kinds/structure/adapter.ts index c13041f7..62baccf4 100644 --- a/apps/web/lib/catalogue-import/kinds/structure/adapter.ts +++ b/apps/web/lib/catalogue-import/kinds/structure/adapter.ts @@ -1,5 +1,5 @@ import type { CatalogueKindAdapter } from "../../kind-adapter.ts"; -import { structureSnapshotWrite } from "../../snapshot-write.ts"; +import { structureCatalogueContent } from "../../../catalogue/content.ts"; import { ACADEMIC_STRUCTURE_EXTRACTION_JSON_SCHEMA, type AcademicStructureExtraction, @@ -200,7 +200,7 @@ export const structureKindAdapter: CatalogueKindAdapter ({ fieldPath: item.fieldKey, diff --git a/apps/web/lib/catalogue-import/manual-snapshot.ts b/apps/web/lib/catalogue-import/manual-version.ts similarity index 55% rename from apps/web/lib/catalogue-import/manual-snapshot.ts rename to apps/web/lib/catalogue-import/manual-version.ts index c9470341..a68fd8d7 100644 --- a/apps/web/lib/catalogue-import/manual-snapshot.ts +++ b/apps/web/lib/catalogue-import/manual-version.ts @@ -1,8 +1,11 @@ import { diffSnapshotWrites } from "./changes.ts"; import { type ImportSql, withImportDatabaseClient } from "./import-store.ts"; -import { insertSnapshotContent } from "./persist-snapshot.ts"; -import { contentHashForWrite, readSnapshotWrite } from "./snapshot-read.ts"; -import type { CatalogueSnapshotWrite } from "./snapshot-write.ts"; +import { insertVersionContent } from "./persist-version.ts"; +import { + contentHashForCatalogueContent, + readVersionContent, +} from "./version-content.ts"; +import type { CatalogueContent } from "../catalogue/content.ts"; export class ManualSnapshotError extends Error { readonly code: string; @@ -15,32 +18,47 @@ export class ManualSnapshotError extends Error { } /** - * Saves an edited write as a new manual draft based on the snapshot the + * Saves edited content as a new manual version based on the version the * editor started from. Open review changes on the edited fields close as * rejected, since the administrator has decided the value directly. */ -export async function saveManualSnapshot({ - itemYearId, +export async function saveManualVersion({ + recordId, baseSnapshotId, write, userId, sql, }: { - itemYearId: number; + recordId: number; baseSnapshotId: number | null; - write: CatalogueSnapshotWrite; + write: CatalogueContent; userId: string; sql?: ImportSql; }) { const work = async (client: ImportSql) => client.begin(async (tx) => { const [itemYear] = await tx` - select item_years.id, item_years.kind, item_years.academic_year_id, item_years.draft_snapshot_id, - item_years.published_snapshot_id, item_years.archived_at, items.code, academic_years.year - from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id + select item_years.id, item_years.kind, item_years.academic_year_id, + item_years.archived_at, items.code, academic_years.year, + current_version.id as current_version_id + from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id join public.academic_years on academic_years.id = item_years.academic_year_id - where item_years.id = ${itemYearId} + left join lateral ( + select versions.id + from public.catalogue_versions as versions + left join public.catalogue_import_targets as targets + on targets.id = versions.import_target_id + where versions.record_id = item_years.id + and versions.sealed_at is not null + and ( + versions.import_target_id is null + or targets.applied_version_id = versions.id + ) + order by versions.created_at desc, versions.id desc + limit 1 + ) as current_version on true + where item_years.id = ${recordId} for update of item_years `; if (!itemYear) @@ -58,11 +76,9 @@ export async function saveManualSnapshot({ ); } const currentBase = - itemYear.draft_snapshot_id === null - ? itemYear.published_snapshot_id === null - ? null - : Number(itemYear.published_snapshot_id) - : Number(itemYear.draft_snapshot_id); + itemYear.current_version_id === null + ? null + : Number(itemYear.current_version_id); if (currentBase !== baseSnapshotId) { throw new ManualSnapshotError( "The record changed while you were editing. Reload and apply your changes again.", @@ -71,10 +87,13 @@ export async function saveManualSnapshot({ } const baseWrite = baseSnapshotId - ? await readSnapshotWrite(tx, baseSnapshotId) + ? await readVersionContent(tx, baseSnapshotId) : null; - const contentHash = contentHashForWrite(write); - if (baseWrite && contentHashForWrite(baseWrite) === contentHash) { + const contentHash = contentHashForCatalogueContent(write); + if ( + baseWrite && + contentHashForCatalogueContent(baseWrite) === contentHash + ) { return { snapshotId: baseSnapshotId!, unchanged: true, @@ -86,15 +105,15 @@ export async function saveManualSnapshot({ ); const [snapshot] = await tx` - insert into public.catalogue_snapshots ( - item_year_id, kind, academic_year_id, origin, based_on_snapshot_id, content_hash, created_by + insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, based_on_version_id, content_hash, created_by ) values ( - ${itemYearId}, ${itemYear.kind}, ${itemYear.academic_year_id}, 'manual', ${baseSnapshotId}, + ${recordId}, ${itemYear.kind}, ${itemYear.academic_year_id}, 'manual', ${baseSnapshotId}, ${contentHash}, ${userId}::uuid ) returning id `; const snapshotId = Number(snapshot.id); - await insertSnapshotContent(tx, { + await insertVersionContent(tx, { snapshotId, kind: write.kind, academicYearId: Number(itemYear.academic_year_id), @@ -108,7 +127,11 @@ export async function saveManualSnapshot({ })), }, }); - await tx`update public.catalogue_item_years set draft_snapshot_id = ${snapshotId} where id = ${itemYearId}`; + await tx` + update public.catalogue_versions + set sealed_at = greatest(statement_timestamp(), created_at) + where id = ${snapshotId} + `; if (editedPaths.length > 0) { await tx` @@ -117,7 +140,7 @@ export async function saveManualSnapshot({ resolution_note = 'Superseded by a manual edit.' from public.catalogue_import_targets as targets where targets.id = changes.target_id - and targets.item_year_id = ${itemYearId} + and targets.record_id = ${recordId} and targets.applied_at is null and changes.entry_kind = 'change' and changes.status = 'open' @@ -129,35 +152,49 @@ export async function saveManualSnapshot({ return sql ? work(sql) : withImportDatabaseClient(work); } -/** Makes a historical snapshot the draft again by copying it as a manual snapshot. */ +/** Restores historical content by copying it into a new manual version. */ export async function restoreSnapshot({ - itemYearId, + recordId, snapshotId, userId, sql, }: { - itemYearId: number; + recordId: number; snapshotId: number; userId: string; sql?: ImportSql; }) { const work = async (client: ImportSql) => { - const write = await readSnapshotWrite(client, snapshotId); + const write = await readVersionContent(client, snapshotId); if (!write) throw new ManualSnapshotError("The snapshot does not exist.", "P0002"); const [itemYear] = await client` - select draft_snapshot_id, published_snapshot_id from public.catalogue_item_years where id = ${itemYearId} + select versions.id as current_version_id + from public.catalogue_records as records + left join lateral ( + select versions.id + from public.catalogue_versions as versions + left join public.catalogue_import_targets as targets + on targets.id = versions.import_target_id + where versions.record_id = records.id + and versions.sealed_at is not null + and ( + versions.import_target_id is null + or targets.applied_version_id = versions.id + ) + order by versions.created_at desc, versions.id desc + limit 1 + ) as versions on true + where records.id = ${recordId} `; if (!itemYear) throw new ManualSnapshotError("The record does not exist.", "P0002"); const baseSnapshotId = - itemYear.draft_snapshot_id === null - ? itemYear.published_snapshot_id === null - ? null - : Number(itemYear.published_snapshot_id) - : Number(itemYear.draft_snapshot_id); - return saveManualSnapshot({ - itemYearId, + itemYear.current_version_id === null + ? null + : Number(itemYear.current_version_id); + return saveManualVersion({ + recordId, baseSnapshotId, write, userId, diff --git a/apps/web/lib/catalogue-import/persist-snapshot.ts b/apps/web/lib/catalogue-import/persist-version.ts similarity index 79% rename from apps/web/lib/catalogue-import/persist-snapshot.ts rename to apps/web/lib/catalogue-import/persist-version.ts index 98ee3652..3ba694d7 100644 --- a/apps/web/lib/catalogue-import/persist-snapshot.ts +++ b/apps/web/lib/catalogue-import/persist-version.ts @@ -5,29 +5,29 @@ import { type SnapshotChange, } from "./changes.ts"; import type { ClaimedImportTarget, ImportSql } from "./import-store.ts"; -import { readSnapshotWrite } from "./snapshot-read.ts"; +import { readVersionContent } from "./version-content.ts"; import type { CatalogueKind, - CatalogueSnapshotWrite, + CatalogueContent, RequirementWrite, -} from "./snapshot-write.ts"; +} from "../catalogue/content.ts"; export type SnapshotChangeKind = "new" | "changed" | "unchanged"; export type PersistedSnapshotCandidate = { changeKind: SnapshotChangeKind; - candidateSnapshotId: number | null; - baselineSnapshotId: number | null; + candidateVersionId: number | null; + baselineVersionId: number | null; becameDraft: boolean; changeSet: { changeKind: SnapshotChangeKind; contentHash: string; - baselineSnapshotId: number | null; + baselineVersionId: number | null; baselineContentHash: string | null; - candidateSnapshotId: number | null; + candidateVersionId: number | null; becameDraft: boolean; changes: SnapshotChange[]; - flags: CatalogueSnapshotWrite["flags"]; + flags: CatalogueContent["flags"]; }; }; @@ -43,7 +43,7 @@ function isValidCode(kind: CatalogueKind, code: string) { } /** Every catalogue code the write refers to, so placeholder identities exist. */ -function referencedItems(write: CatalogueSnapshotWrite) { +function referencedItems(write: CatalogueContent) { const items = new Map(); const add = (kind: CatalogueKind | null, code: string | null) => { if (!kind || !code) return; @@ -72,12 +72,12 @@ async function ensureItemIds( const ids = new Map(); for (const item of items) { await tx` - insert into public.catalogue_items (kind, code) + insert into public.catalogue_codes (kind, code) values (${item.kind}, ${item.code}) on conflict (kind, code) do nothing `; const [row] = await tx` - select id from public.catalogue_items where kind = ${item.kind} and code = ${item.code} + select id from public.catalogue_codes where kind = ${item.kind} and code = ${item.code} `; ids.set(`${item.kind}:${item.code}`, Number(row.id)); } @@ -99,12 +99,12 @@ async function insertCourseContent( academicYearId: number, sourcePageId: number | null, ids: Map, - content: NonNullable, + content: NonNullable, ) { const details = content.details; await tx` - insert into public.course_snapshot_details ( - snapshot_id, title, unit_value_kind, units, minimum_units, maximum_units, eftsl, + insert into public.course_version_details ( + version_id, title, unit_value_kind, units, minimum_units, maximum_units, eftsl, level, subject_code, subject_name, school, college, academic_career, convener_text, delivery_summary, introduction, description, workload_text, workload_hours, inherent_requirements, prescribed_texts, offering_status, @@ -121,14 +121,14 @@ async function insertCourseContent( `; for (const option of content.unitOptions) { await tx` - insert into public.course_unit_options (snapshot_id, position, units, label, source_text) + insert into public.course_unit_options (version_id, position, units, label, source_text) values (${snapshotId}, ${option.position}, ${option.units}, ${option.label}, ${option.sourceText}) `; } for (const fee of content.fees) { await tx` insert into public.course_fees ( - snapshot_id, position, fee_year, audience, fee_type, amount, currency, basis, + version_id, position, fee_year, audience, fee_type, amount, currency, basis, student_contribution_band, source_label, source_text ) values ( ${snapshotId}, ${fee.position}, ${fee.feeYear}, ${fee.audience}, ${fee.feeType}, @@ -139,13 +139,13 @@ async function insertCourseContent( } for (const area of content.areasOfInterest) { await tx` - insert into public.course_areas_of_interest (snapshot_id, position, name) + insert into public.course_areas_of_interest (version_id, position, name) values (${snapshotId}, ${area.position}, ${area.name}) `; } for (const attribute of content.attributes) { await tx` - insert into public.course_attributes (snapshot_id, position, attribute_kind, value, source_text) + insert into public.course_attributes (version_id, position, attribute_kind, value, source_text) values (${snapshotId}, ${attribute.position}, ${attribute.attributeKind}, ${attribute.value}, ${attribute.sourceText}) `; } @@ -154,7 +154,7 @@ async function insertCourseContent( if (relatedId === null) continue; await tx` insert into public.course_related_courses ( - snapshot_id, position, relation_kind, related_course_id, source_course_code, + version_id, position, relation_kind, related_course_id, source_course_code, source_course_title, source_text ) values ( ${snapshotId}, ${related.position}, ${related.relationKind}, ${relatedId}, @@ -163,7 +163,7 @@ async function insertCourseContent( `; } const [offering] = await tx` - insert into public.course_offerings (snapshot_id, academic_year_id, source_page_id, delivery_mode, location) + insert into public.course_offerings (version_id, academic_year_id, source_page_id, delivery_mode, location) values ( ${snapshotId}, ${academicYearId}, ${sourcePageId}, ${content.offering?.deliveryMode ?? null}, ${content.offering?.location ?? null} @@ -173,7 +173,7 @@ async function insertCourseContent( for (const session of content.sessions) { await tx` insert into public.offering_sessions ( - course_offering_id, snapshot_id, academic_year_id, source_page_id, + course_offering_id, version_id, academic_year_id, source_page_id, academic_period_id, academic_period_code, academic_period_name, position, class_number, starts_on, enrol_closes_on, census_on, ends_on, delivery_mode, location, class_summary_url, source_text @@ -194,7 +194,7 @@ async function insertCourseContent( const outcomeIds = new Map(); for (const outcome of content.learningOutcomes) { const [row] = await tx` - insert into public.course_learning_outcomes (snapshot_id, position, body) + insert into public.course_learning_outcomes (version_id, position, body) values (${snapshotId}, ${outcome.position}, ${outcome.body}) returning id `; @@ -204,7 +204,7 @@ async function insertCourseContent( for (const item of content.assessmentItems) { const [row] = await tx` insert into public.course_assessment_items ( - snapshot_id, position, title, weight, hurdle, due_text, source_text + version_id, position, title, weight, hurdle, due_text, source_text ) values ( ${snapshotId}, ${item.position}, ${item.title}, ${item.weight}, ${item.hurdle}, ${item.dueText}, ${item.sourceText} @@ -218,7 +218,7 @@ async function insertCourseContent( const outcomeId = outcomeIds.get(link.learningOutcomePosition); if (!assessmentId || !outcomeId) continue; await tx` - insert into public.course_assessment_outcomes (snapshot_id, assessment_item_id, learning_outcome_id) + insert into public.course_assessment_outcomes (version_id, assessment_item_id, learning_outcome_id) values (${snapshotId}, ${assessmentId}, ${outcomeId}) on conflict do nothing `; @@ -229,12 +229,12 @@ async function insertStructureContent( tx: Tx, snapshotId: number, kind: CatalogueKind, - content: NonNullable, + content: NonNullable, ) { const details = content.details; await tx` - insert into public.structure_snapshot_details ( - snapshot_id, kind, name, acronym, short_name, introduction, description, units, + insert into public.structure_version_details ( + version_id, kind, name, acronym, short_name, introduction, description, units, duration_years, academic_career, college, mode_of_delivery, selection_rank, atar, can_combine, can_combine_vertical, study_as, contact_text ) values ( @@ -249,7 +249,7 @@ async function insertStructureContent( for (const field of content.summaryFields) { await tx` insert into public.structure_snapshot_summary_fields ( - snapshot_id, position, value_position, field_key, label, field_value, source_text + version_id, position, value_position, field_key, label, field_value, source_text ) values ( ${snapshotId}, ${field.position}, ${field.valuePosition}, ${field.fieldKey}, ${field.label}, ${field.fieldValue}, ${field.sourceText} @@ -259,7 +259,7 @@ async function insertStructureContent( for (const section of content.sections) { await tx` insert into public.academic_structure_snapshot_sections ( - snapshot_id, section_key, heading, markdown, source_text, source_locator, position + version_id, section_key, heading, markdown, source_text, source_locator, position ) values ( ${snapshotId}, ${section.sectionKey}, ${section.heading}, ${section.markdown}, ${section.sourceText}, ${section.sourceLocator}, ${section.position} @@ -269,7 +269,7 @@ async function insertStructureContent( for (const outcome of content.learningOutcomes) { await tx` insert into public.academic_structure_learning_outcomes ( - snapshot_id, position, outcome_text, source_text, source_locator + version_id, position, outcome_text, source_text, source_locator ) values ( ${snapshotId}, ${outcome.position}, ${outcome.outcomeText}, ${outcome.sourceText}, ${outcome.sourceLocator} @@ -279,7 +279,7 @@ async function insertStructureContent( for (const fee of content.fees) { await tx` insert into public.academic_structure_fees ( - snapshot_id, position, fee_year, audience, fee_type, amount, currency, basis, + version_id, position, fee_year, audience, fee_type, amount, currency, basis, source_label, source_text, source_locator ) values ( ${snapshotId}, ${fee.position}, ${fee.feeYear}, ${fee.audience}, ${fee.feeType}, @@ -291,7 +291,7 @@ async function insertStructureContent( for (const relationship of content.relationships) { await tx` insert into public.academic_structure_snapshot_relationships ( - snapshot_id, position, relationship_kind, target_kind, target_code, target_title, + version_id, position, relationship_kind, target_kind, target_code, target_title, source_text, source_locator ) values ( ${snapshotId}, ${relationship.position}, ${relationship.relationshipKind}, @@ -314,7 +314,7 @@ async function insertRequirements( for (const rule of requirements.rules) { const [row] = await tx` insert into public.requirement_rules ( - snapshot_id, academic_year_id, source_page_id, rule_kind, hardness, source_text, + version_id, academic_year_id, source_page_id, rule_kind, hardness, source_text, source_locator, review_state, confidence, position ) values ( ${snapshotId}, ${academicYearId}, ${sourcePageId}, ${rule.key}, ${rule.hardness}, @@ -343,7 +343,7 @@ async function insertRequirements( } const [row] = await tx` insert into public.requirement_groups ( - rule_id, snapshot_id, parent_group_id, group_key, label, description, operator, + rule_id, version_id, parent_group_id, group_key, label, description, operator, minimum_count, minimum_units, maximum_units, source_text, source_locator, position ) values ( ${ruleId}, ${snapshotId}, @@ -368,12 +368,12 @@ async function insertRequirements( `Requirement condition ${condition.key} has no group.`, ); } - // item_kind travels with item_id so the composite foreign key can hold the + // item_kind travels with code_id so the composite foreign key can hold the // referenced item to the kind the condition expects. const conditionItemId = itemId(ids, condition.itemKind, condition.itemCode); const [row] = await tx` insert into public.requirement_conditions ( - rule_id, snapshot_id, group_id, condition_key, position, condition_kind, item_id, + rule_id, version_id, group_id, condition_key, position, condition_kind, code_id, item_kind, structure_kind, requirement_mode, minimum_mark, minimum_units, maximum_units, minimum_count, subject_code, minimum_level, maximum_level, minimum_year, minimum_gpa, minimum_wam, tag, free_text, hardness, source_text, source_locator, @@ -402,7 +402,7 @@ async function insertRequirements( if (!isValidCode(option.kind, code)) continue; await tx` insert into public.requirement_condition_options ( - condition_id, snapshot_id, position, kind, code, item_id, title, source_text + condition_id, version_id, position, kind, code, code_id, title, source_text ) values ( ${conditionId}, ${snapshotId}, ${option.position}, ${option.kind}, ${code}, ${itemId(ids, option.kind, code)}, ${option.title}, ${option.sourceText} @@ -417,18 +417,18 @@ async function insertRequirements( if (ruleId === undefined || referencedId === null) continue; await tx` insert into public.requirement_item_references ( - rule_id, snapshot_id, item_id, source_text, confidence, review_state + rule_id, version_id, code_id, source_text, confidence, review_state ) values ( ${ruleId}, ${snapshotId}, ${referencedId}, ${reference.sourceText}, ${reference.confidence}, ${reference.reviewState} ) - on conflict (rule_id, item_id) do nothing + on conflict (rule_id, code_id) do nothing `; } } /** Writes every content, requirement and evidence row for a new snapshot. */ -export async function insertSnapshotContent( +export async function insertVersionContent( tx: Tx, { snapshotId, @@ -441,7 +441,7 @@ export async function insertSnapshotContent( kind: CatalogueKind; academicYearId: number; sourcePageId: number | null; - write: CatalogueSnapshotWrite; + write: CatalogueContent; }, ) { const ids = await ensureItemIds(tx, referencedItems(write)); @@ -468,8 +468,8 @@ export async function insertSnapshotContent( ); for (const evidence of write.evidence) { await tx` - insert into public.snapshot_field_evidence ( - snapshot_id, academic_year_id, source_page_id, field_path, method, confidence, + insert into public.catalogue_version_provenance ( + version_id, academic_year_id, source_page_id, field_path, method, confidence, source_locator, source_excerpt ) values ( ${snapshotId}, ${academicYearId}, ${sourcePageId}, ${evidence.fieldPath}, @@ -491,7 +491,7 @@ export async function insertImportChanges( }: { targetId: string; changes: SnapshotChange[]; - flags: CatalogueSnapshotWrite["flags"]; + flags: CatalogueContent["flags"]; acceptAll: boolean; }, ) { @@ -526,12 +526,12 @@ export async function insertImportChanges( } /** - * Assembles a candidate snapshot for an import target. Returns `unchanged` + * Assembles a candidate version for an import target. Returns `unchanged` * without writing when the content hash matches the baseline. A first import - * for an item year becomes its draft immediately with every change accepted; + * for a record becomes its applied version immediately with every change accepted; * otherwise the changes stay open for review. */ -export async function persistSnapshotCandidate( +export async function persistVersionCandidate( sql: ImportSql, { claim, @@ -540,7 +540,7 @@ export async function persistSnapshotCandidate( }: { claim: ClaimedImportTarget; sourcePageId: number | null; - write: CatalogueSnapshotWrite; + write: CatalogueContent; }, ): Promise { if ( @@ -555,23 +555,15 @@ export async function persistSnapshotCandidate( return sql.begin(async (tx) => { const [itemYear] = await tx` - select id, draft_snapshot_id, published_snapshot_id - from public.catalogue_item_years - where id = ${claim.itemYearId} + select id + from public.catalogue_records + where id = ${claim.recordId} for update `; if (!itemYear) throw new Error("The catalogue item year was not resolved."); - const draftId = - itemYear.draft_snapshot_id === null - ? null - : Number(itemYear.draft_snapshot_id); - const publishedId = - itemYear.published_snapshot_id === null - ? null - : Number(itemYear.published_snapshot_id); - const baselineSnapshotId = draftId ?? publishedId; - const [baseline] = baselineSnapshotId - ? await tx`select content_hash from public.catalogue_snapshots where id = ${baselineSnapshotId}` + const baselineVersionId = claim.baselineVersionId; + const [baseline] = baselineVersionId + ? await tx`select content_hash from public.catalogue_versions where id = ${baselineVersionId}` : []; const baselineContentHash = baseline ? String(baseline.content_hash) : null; @@ -584,15 +576,15 @@ export async function persistSnapshotCandidate( }); return { changeKind: "unchanged" as const, - candidateSnapshotId: null, - baselineSnapshotId, + candidateVersionId: null, + baselineVersionId, becameDraft: false, changeSet: { changeKind: "unchanged" as const, contentHash: write.contentHash, - baselineSnapshotId, + baselineVersionId, baselineContentHash, - candidateSnapshotId: null, + candidateVersionId: null, becameDraft: false, changes: [], flags: write.flags, @@ -600,38 +592,43 @@ export async function persistSnapshotCandidate( }; } - const baselineWrite = baselineSnapshotId - ? await readSnapshotWrite(tx, baselineSnapshotId) + const baselineWrite = baselineVersionId + ? await readVersionContent(tx, baselineVersionId) : null; const changes = diffSnapshotWrites(baselineWrite, write); if (claim.directoryEntryId !== null) { await tx` update public.catalogue_directory_entries - set item_id = ${claim.itemId} - where id = ${claim.directoryEntryId} and item_id is null + set code_id = ${claim.itemId} + where id = ${claim.directoryEntryId} and code_id is null `; } const [snapshot] = await tx` - insert into public.catalogue_snapshots ( - item_year_id, kind, academic_year_id, origin, based_on_snapshot_id, source_page_id, + insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, based_on_version_id, source_page_id, content_hash, import_target_id ) values ( - ${claim.itemYearId}, ${claim.kind}, ${claim.academicYearId}, 'import', - ${baselineSnapshotId}, ${sourcePageId}, ${write.contentHash}, ${claim.targetId}::uuid + ${claim.recordId}, ${claim.kind}, ${claim.academicYearId}, 'import', + ${baselineVersionId}, ${sourcePageId}, ${write.contentHash}, ${claim.targetId}::uuid ) returning id `; const snapshotId = Number(snapshot.id); - await insertSnapshotContent(tx, { + await insertVersionContent(tx, { snapshotId, kind: claim.kind, academicYearId: claim.academicYearId, sourcePageId, write, }); + await tx` + update public.catalogue_versions + set sealed_at = greatest(statement_timestamp(), created_at) + where id = ${snapshotId} + `; - const becameDraft = baselineSnapshotId === null; + const becameDraft = baselineVersionId === null; await insertImportChanges(tx, { targetId: claim.targetId, changes, @@ -639,34 +636,29 @@ export async function persistSnapshotCandidate( acceptAll: becameDraft, }); if (becameDraft) { - await tx` - update public.catalogue_item_years - set draft_snapshot_id = ${snapshotId} - where id = ${claim.itemYearId} - `; // A first import has nothing to compare against, so its changes are - // recorded as already accepted and the candidate becomes the draft + // recorded as already accepted and the candidate becomes the applied version // without anyone pressing Apply. Recording that here keeps the target - // honest: it was applied, and leaving applied_snapshot_id null made a + // honest: it was applied, and leaving applied_version_id null made a // published record still read "Ready for review". await tx` update public.catalogue_import_targets - set applied_snapshot_id = ${snapshotId}, applied_at = now() + set applied_version_id = ${snapshotId}, applied_at = now() where id = ${claim.targetId}::uuid `; } const changeKind: SnapshotChangeKind = becameDraft ? "new" : "changed"; return { changeKind, - candidateSnapshotId: snapshotId, - baselineSnapshotId, + candidateVersionId: snapshotId, + baselineVersionId, becameDraft, changeSet: { changeKind, contentHash: write.contentHash, - baselineSnapshotId, + baselineVersionId, baselineContentHash, - candidateSnapshotId: snapshotId, + candidateVersionId: snapshotId, becameDraft, changes, flags: write.flags, diff --git a/apps/web/lib/catalogue-import/process-target.ts b/apps/web/lib/catalogue-import/process-target.ts index f3e8f7ad..034ae274 100644 --- a/apps/web/lib/catalogue-import/process-target.ts +++ b/apps/web/lib/catalogue-import/process-target.ts @@ -35,8 +35,8 @@ import { extractWithOpenRouter, restoreOpenRouterExtraction, } from "./openrouter.ts"; -import { persistSnapshotCandidate } from "./persist-snapshot.ts"; -import type { CatalogueKind } from "./snapshot-write.ts"; +import { persistVersionCandidate } from "./persist-version.ts"; +import type { CatalogueKind } from "../catalogue/content.ts"; const TERMINAL_TARGET_STATUSES = new Set([ "ready", @@ -550,7 +550,7 @@ async function processClaimedTarget({ }); const persisted = await runStage("snapshot_persist", async (stageId) => { - const result = await persistSnapshotCandidate(sql, { + const result = await persistVersionCandidate(sql, { claim, sourcePageId, write, @@ -573,7 +573,7 @@ async function processClaimedTarget({ status: persisted.changeKind === "unchanged" ? "unchanged" : "ready", changeKind: persisted.changeKind, sourcePageId, - candidateSnapshotId: persisted.candidateSnapshotId, + candidateVersionId: persisted.candidateVersionId, // Discarding the model extraction used to be silent: the target ended // `ready` with no error code, and only catalogue_extractions recorded // it. The blocking flag the merge emitted holds publication; this says @@ -607,7 +607,7 @@ async function processClaimedTarget({ status: "failed", changeKind: null, sourcePageId, - candidateSnapshotId: null, + candidateVersionId: null, errorCode: code, errorMessage: summary, }); diff --git a/apps/web/lib/catalogue-import/requirement-tree.ts b/apps/web/lib/catalogue-import/requirement-tree.ts index 5ef9dca1..e2773c48 100644 --- a/apps/web/lib/catalogue-import/requirement-tree.ts +++ b/apps/web/lib/catalogue-import/requirement-tree.ts @@ -7,7 +7,7 @@ import type { CatalogueKind, RequirementRuleKind, RequirementWrite, -} from "./snapshot-write.ts"; +} from "../catalogue/content.ts"; /** Condition kinds the drag-and-drop editor can represent. */ const EDITABLE_KINDS = new Set([ diff --git a/apps/web/lib/catalogue-import/snapshot-read.ts b/apps/web/lib/catalogue-import/version-content.ts similarity index 83% rename from apps/web/lib/catalogue-import/snapshot-read.ts rename to apps/web/lib/catalogue-import/version-content.ts index 11496cf4..64aa64a4 100644 --- a/apps/web/lib/catalogue-import/snapshot-read.ts +++ b/apps/web/lib/catalogue-import/version-content.ts @@ -3,12 +3,14 @@ import { stableFingerprint } from "./canonical.ts"; import type { ImportSql, ImportTransactionSql } from "./import-store.ts"; import type { CatalogueKind, - CatalogueSnapshotWrite, + CatalogueContent, + CourseContentWrite, RequirementConditionKind, RequirementRuleKind, RequirementWrite, ReviewState, -} from "./snapshot-write.ts"; + StructureContentWrite, +} from "../catalogue/content.ts"; type Sql = ImportSql | ImportTransactionSql | postgres.Sql; @@ -33,7 +35,7 @@ async function readRequirements( ): Promise { const rules = await sql` select id, rule_kind, hardness, source_text, source_locator, review_state, confidence, position - from public.requirement_rules where snapshot_id = ${snapshotId} order by position, rule_kind + from public.requirement_rules where version_id = ${snapshotId} order by position, rule_kind `; const ruleKeyById = new Map( rules.map((rule) => [ @@ -44,7 +46,7 @@ async function readRequirements( const groups = await sql` select id, rule_id, parent_group_id, group_key, label, description, operator, minimum_count, minimum_units, maximum_units, source_text, source_locator, position - from public.requirement_groups where snapshot_id = ${snapshotId} order by rule_id, position, id + from public.requirement_groups where version_id = ${snapshotId} order by rule_id, position, id `; const groupKeyById = new Map( groups.map((group) => [Number(group.id), String(group.group_key)]), @@ -52,8 +54,8 @@ async function readRequirements( const conditions = await sql` select conditions.*, items.code as item_code, items.kind as item_kind from public.requirement_conditions as conditions - left join public.catalogue_items as items on items.id = conditions.item_id - where conditions.snapshot_id = ${snapshotId} + left join public.catalogue_codes as items on items.id = conditions.code_id + where conditions.version_id = ${snapshotId} order by conditions.rule_id, conditions.position, conditions.id `; const conditionKeyById = new Map( @@ -64,15 +66,15 @@ async function readRequirements( ); const options = await sql` select condition_id, position, kind, code, title, source_text - from public.requirement_condition_options where snapshot_id = ${snapshotId} + from public.requirement_condition_options where version_id = ${snapshotId} order by condition_id, position `; const references = await sql` select item_references.rule_id, items.code, item_references.source_text, item_references.confidence, item_references.review_state from public.requirement_item_references as item_references - join public.catalogue_items as items on items.id = item_references.item_id - where item_references.snapshot_id = ${snapshotId} + join public.catalogue_codes as items on items.id = item_references.code_id + where item_references.version_id = ${snapshotId} order by item_references.rule_id, items.code `; return { @@ -153,9 +155,9 @@ async function readRequirements( async function readCourseContent( sql: Sql, snapshotId: number, -): Promise { +): Promise { const [details] = await sql` - select * from public.course_snapshot_details where snapshot_id = ${snapshotId} + select * from public.course_version_details where version_id = ${snapshotId} `; if (!details) return null; const [ @@ -170,16 +172,16 @@ async function readCourseContent( assessments, links, ] = await Promise.all([ - sql`select position, units, label, source_text from public.course_unit_options where snapshot_id = ${snapshotId} order by position`, - sql`select * from public.course_fees where snapshot_id = ${snapshotId} order by position`, - sql`select position, name from public.course_areas_of_interest where snapshot_id = ${snapshotId} order by position`, - sql`select position, attribute_kind, value, source_text from public.course_attributes where snapshot_id = ${snapshotId} order by position`, - sql`select position, relation_kind, source_course_code, source_course_title, source_text from public.course_related_courses where snapshot_id = ${snapshotId} order by position`, - sql`select delivery_mode, location from public.course_offerings where snapshot_id = ${snapshotId} limit 1`, - sql`select sessions.*, academic_years.year as calendar_year from public.offering_sessions as sessions join public.academic_years on academic_years.id = sessions.academic_year_id where sessions.snapshot_id = ${snapshotId} order by sessions.position`, - sql`select id, position, body from public.course_learning_outcomes where snapshot_id = ${snapshotId} order by position`, - sql`select id, position, title, weight, hurdle, due_text, source_text from public.course_assessment_items where snapshot_id = ${snapshotId} order by position`, - sql`select assessment_item_id, learning_outcome_id from public.course_assessment_outcomes where snapshot_id = ${snapshotId}`, + sql`select position, units, label, source_text from public.course_unit_options where version_id = ${snapshotId} order by position`, + sql`select * from public.course_fees where version_id = ${snapshotId} order by position`, + sql`select position, name from public.course_areas_of_interest where version_id = ${snapshotId} order by position`, + sql`select position, attribute_kind, value, source_text from public.course_attributes where version_id = ${snapshotId} order by position`, + sql`select position, relation_kind, source_course_code, source_course_title, source_text from public.course_related_courses where version_id = ${snapshotId} order by position`, + sql`select delivery_mode, location from public.course_offerings where version_id = ${snapshotId} limit 1`, + sql`select sessions.*, academic_years.year as calendar_year from public.offering_sessions as sessions join public.academic_years on academic_years.id = sessions.academic_year_id where sessions.version_id = ${snapshotId} order by sessions.position`, + sql`select id, position, body from public.course_learning_outcomes where version_id = ${snapshotId} order by position`, + sql`select id, position, title, weight, hurdle, due_text, source_text from public.course_assessment_items where version_id = ${snapshotId} order by position`, + sql`select assessment_item_id, learning_outcome_id from public.course_assessment_outcomes where version_id = ${snapshotId}`, ]); const outcomePosition = new Map( outcomes.map((row) => [Number(row.id), Number(row.position)]), @@ -302,18 +304,18 @@ async function readCourseContent( async function readStructureContent( sql: Sql, snapshotId: number, -): Promise { +): Promise { const [details] = await sql` - select * from public.structure_snapshot_details where snapshot_id = ${snapshotId} + select * from public.structure_version_details where version_id = ${snapshotId} `; if (!details) return null; const [summaryFields, sections, outcomes, fees, relationships] = await Promise.all([ - sql`select position, value_position, field_key, label, field_value, source_text from public.structure_snapshot_summary_fields where snapshot_id = ${snapshotId} order by position, value_position`, - sql`select section_key, heading, markdown, source_text, source_locator, position from public.academic_structure_snapshot_sections where snapshot_id = ${snapshotId} order by position`, - sql`select position, outcome_text, source_text, source_locator from public.academic_structure_learning_outcomes where snapshot_id = ${snapshotId} order by position`, - sql`select * from public.academic_structure_fees where snapshot_id = ${snapshotId} order by position`, - sql`select * from public.academic_structure_snapshot_relationships where snapshot_id = ${snapshotId} order by position`, + sql`select position, value_position, field_key, label, field_value, source_text from public.structure_snapshot_summary_fields where version_id = ${snapshotId} order by position, value_position`, + sql`select section_key, heading, markdown, source_text, source_locator, position from public.academic_structure_snapshot_sections where version_id = ${snapshotId} order by position`, + sql`select position, outcome_text, source_text, source_locator from public.academic_structure_learning_outcomes where version_id = ${snapshotId} order by position`, + sql`select * from public.academic_structure_fees where version_id = ${snapshotId} order by position`, + sql`select * from public.academic_structure_snapshot_relationships where version_id = ${snapshotId} order by position`, ]); return { details: { @@ -371,7 +373,7 @@ async function readStructureContent( sourceLabel: str(row.source_label), sourceText: String(row.source_text), sourceLocator: String(row.source_locator), - })) as NonNullable["fees"], + })) as NonNullable["fees"], relationships: relationships.map((row) => ({ position: Number(row.position), relationshipKind: row.relationship_kind, @@ -380,7 +382,7 @@ async function readStructureContent( targetTitle: str(row.target_title), sourceText: String(row.source_text), sourceLocator: String(row.source_locator), - })) as NonNullable["relationships"], + })) as NonNullable["relationships"], }; } @@ -389,50 +391,48 @@ async function readStructureContent( * against a candidate or edited and saved as a new snapshot. Evidence is not * carried across; a derived snapshot records its own. */ -export async function readSnapshotWrite( +export async function readVersionContent( sql: Sql, snapshotId: number, -): Promise { +): Promise { const [snapshot] = await sql` select snapshots.kind, snapshots.content_hash, items.code, academic_years.year - from public.catalogue_snapshots as snapshots - join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id - join public.catalogue_items as items on items.id = item_years.item_id + from public.catalogue_versions as snapshots + join public.catalogue_records as item_years on item_years.id = snapshots.record_id + join public.catalogue_codes as items on items.id = item_years.code_id join public.academic_years on academic_years.id = snapshots.academic_year_id where snapshots.id = ${snapshotId} `; if (!snapshot) return null; const kind = snapshot.kind as CatalogueKind; - const [course, structure, requirements] = await Promise.all([ - kind === "course" - ? readCourseContent(sql, snapshotId) - : Promise.resolve(null), - kind === "course" - ? Promise.resolve(null) - : readStructureContent(sql, snapshotId), - readRequirements(sql, snapshotId), - ]); - return { - kind, + const requirements = await readRequirements(sql, snapshotId); + const common = { code: String(snapshot.code), academicYear: Number(snapshot.year), contentHash: String(snapshot.content_hash), - course, - structure, requirements, evidence: [], flags: [], }; + if (kind === "course") { + const course = await readCourseContent(sql, snapshotId); + return course ? { ...common, kind, course } : null; + } + const structure = await readStructureContent(sql, snapshotId); + return structure ? { ...common, kind, structure } : null; } /** Content hash over everything that reaches the database, ignoring provenance. */ -export function contentHashForWrite(write: CatalogueSnapshotWrite) { - return stableFingerprint({ +export function contentHashForCatalogueContent(write: CatalogueContent) { + const common = { kind: write.kind, code: write.code, academicYear: write.academicYear, - course: write.course, - structure: write.structure, requirements: write.requirements, - }); + }; + return stableFingerprint( + write.kind === "course" + ? { ...common, course: write.course } + : { ...common, structure: write.structure }, + ); } diff --git a/apps/web/lib/catalogue-import/snapshot-write.ts b/apps/web/lib/catalogue/content.ts similarity index 93% rename from apps/web/lib/catalogue-import/snapshot-write.ts rename to apps/web/lib/catalogue/content.ts index 6750d07c..5e470f01 100644 --- a/apps/web/lib/catalogue-import/snapshot-write.ts +++ b/apps/web/lib/catalogue/content.ts @@ -1,5 +1,5 @@ -import type { CourseSnapshotProjection } from "./kinds/course/project.ts"; -import type { AcademicStructureSnapshotProjection } from "./kinds/structure/project.ts"; +import type { CourseSnapshotProjection } from "../catalogue-import/kinds/course/project.ts"; +import type { AcademicStructureSnapshotProjection } from "../catalogue-import/kinds/structure/project.ts"; export type CatalogueKind = "course" | "programme" | "major" | "minor" | "specialisation"; @@ -116,7 +116,7 @@ export type RequirementWrite = { }>; }; -export type SnapshotEvidenceWrite = { +export type CatalogueVersionProvenance = { fieldPath: string; method: "deterministic" | "model" | "manual"; confidence: number | null; @@ -124,7 +124,7 @@ export type SnapshotEvidenceWrite = { sourceExcerpt: string | null; }; -export type SnapshotFlagWrite = { +export type CatalogueContentFlag = { fieldPath: string | null; severity: "warning" | "error"; code: string; @@ -173,22 +173,33 @@ export type StructureContentWrite = { }; /** - * Everything needed to assemble one catalogue snapshot. Kind-specific content - * sits under `course` or `structure`; requirements, evidence and flags use the + * Everything needed to assemble one catalogue version. Kind-specific content + * is discriminated by `kind`; requirements, provenance and flags use the * shared shape. `contentHash` identifies the content for change detection. */ -export type CatalogueSnapshotWrite = { - kind: CatalogueKind; +type CatalogueContentBase = { code: string; academicYear: number; contentHash: string; - course: CourseContentWrite | null; - structure: StructureContentWrite | null; requirements: RequirementWrite; - evidence: SnapshotEvidenceWrite[]; - flags: SnapshotFlagWrite[]; + evidence: CatalogueVersionProvenance[]; + flags: CatalogueContentFlag[]; }; +export type CatalogueContent = CatalogueContentBase & + ( + | { + kind: "course"; + course: CourseContentWrite; + structure?: never; + } + | { + kind: Exclude; + course?: never; + structure: StructureContentWrite; + } + ); + type CourseProjectionCondition = CourseSnapshotProjection["ruleConditions"][number]; @@ -198,15 +209,15 @@ function courseConditionKind( return kind === "admission" ? "structure" : kind; } -export function courseSnapshotWrite({ +export function courseCatalogueContent({ projection, evidence = [], flags = [], }: { projection: CourseSnapshotProjection; - evidence?: SnapshotEvidenceWrite[]; - flags?: SnapshotFlagWrite[]; -}): CatalogueSnapshotWrite { + evidence?: CatalogueVersionProvenance[]; + flags?: CatalogueContentFlag[]; +}): CatalogueContent { const ruleOrder: RequirementRuleKind[] = [ "prerequisite", "corequisite", @@ -232,7 +243,6 @@ export function courseSnapshotWrite({ assessmentItems: projection.assessmentItems, assessmentOutcomes: projection.assessmentOutcomes, }, - structure: null, requirements: { rules: projection.rules.map((rule) => ({ key: rule.ruleKind, @@ -346,15 +356,15 @@ function structureConditionKind( } } -export function structureSnapshotWrite({ +export function structureCatalogueContent({ projection, evidence = [], flags = [], }: { projection: AcademicStructureSnapshotProjection; - evidence?: SnapshotEvidenceWrite[]; - flags?: SnapshotFlagWrite[]; -}): CatalogueSnapshotWrite { + evidence?: CatalogueVersionProvenance[]; + flags?: CatalogueContentFlag[]; +}): CatalogueContent { const hasRequirements = projection.requirementRootKey !== null; const rootSourceText = projection.requirementGroups.find( @@ -376,7 +386,6 @@ export function structureSnapshotWrite({ code: projection.structureCode, academicYear: projection.academicYear, contentHash: projection.projectionSha256, - course: null, structure: { details: { name: projection.snapshot.title, diff --git a/apps/web/lib/coursemap/actions.ts b/apps/web/lib/coursemap/actions.ts index 2c4dba78..69c62b6f 100644 --- a/apps/web/lib/coursemap/actions.ts +++ b/apps/web/lib/coursemap/actions.ts @@ -179,7 +179,7 @@ export async function recordCourseAttempt( if (error) throw error; const { data: storedAttempt, error: storedAttemptError } = await supabase .from("course_attempts") - .select("course_snapshot_id,units_attempted,units_earned") + .select("catalogue_version_id,units_attempted,units_earned") .eq("id", data) .single(); if (storedAttemptError) throw storedAttemptError; @@ -188,7 +188,7 @@ export async function recordCourseAttempt( ok: true, id: data, message: "Academic history updated", - snapshotId: storedAttempt.course_snapshot_id, + snapshotId: storedAttempt.catalogue_version_id, unitsAttempted: Number(storedAttempt.units_attempted), unitsEarned: Number(storedAttempt.units_earned), }; diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 1e870525..8c98b893 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -13,9 +13,9 @@ import { import { ManualSnapshotError, restoreSnapshot, - saveManualSnapshot, -} from "@/lib/catalogue-import/manual-snapshot"; -import type { CatalogueSnapshotWrite } from "@/lib/catalogue-import/snapshot-write"; + saveManualVersion, +} from "@/lib/catalogue-import/manual-version"; +import type { CatalogueContent } from "@/lib/catalogue/content"; import { createClient } from "@/lib/supabase/server"; export type ActionResult = @@ -143,17 +143,17 @@ export async function applyReviewAction({ } export async function publishDraftAction({ - itemYearId, + recordId, path, }: { - itemYearId: number; + recordId: number; path: string; }): Promise { if (!(await canWriteCatalogue())) return { ok: false, error: "Catalogue write permission is required." }; const supabase = await createClient(); - const { error } = await supabase.rpc("publish_catalogue_snapshot", { - p_item_year_id: itemYearId, + const { error } = await supabase.rpc("publish_catalogue_version", { + p_record_id: recordId, }); if (error) return { ok: false, error: error.message }; revalidateRecord(path); @@ -161,17 +161,17 @@ export async function publishDraftAction({ } export async function unpublishAction({ - itemYearId, + recordId, path, }: { - itemYearId: number; + recordId: number; path: string; }): Promise { if (!(await canWriteCatalogue())) return { ok: false, error: "Catalogue write permission is required." }; const supabase = await createClient(); - const { error } = await supabase.rpc("unpublish_catalogue_item_year", { - p_item_year_id: itemYearId, + const { error } = await supabase.rpc("unpublish_catalogue_record", { + p_record_id: recordId, }); if (error) return { ok: false, error: error.message }; revalidateRecord(path); @@ -181,15 +181,15 @@ export async function unpublishAction({ }; } -export async function saveManualSnapshotAction({ - itemYearId, +export async function saveManualVersionAction({ + recordId, baseSnapshotId, write, path, }: { - itemYearId: number; + recordId: number; baseSnapshotId: number | null; - write: CatalogueSnapshotWrite; + write: CatalogueContent; path: string; }): Promise { if (!(await canWriteCatalogue())) @@ -197,8 +197,8 @@ export async function saveManualSnapshotAction({ const viewer = await getAuthViewer(); if (!viewer) return { ok: false, error: "Authentication is required." }; try { - const result = await saveManualSnapshot({ - itemYearId, + const result = await saveManualVersion({ + recordId, baseSnapshotId, write, userId: viewer.id, @@ -219,11 +219,11 @@ export async function saveManualSnapshotAction({ } export async function restoreSnapshotAction({ - itemYearId, + recordId, snapshotId, path, }: { - itemYearId: number; + recordId: number; snapshotId: number; path: string; }): Promise { @@ -233,7 +233,7 @@ export async function restoreSnapshotAction({ if (!viewer) return { ok: false, error: "Authentication is required." }; try { const result = await restoreSnapshot({ - itemYearId, + recordId, snapshotId, userId: viewer.id, }); @@ -250,24 +250,3 @@ export async function restoreSnapshotAction({ return failure(error, "The snapshot could not be restored."); } } - -export async function discardDraftAction({ - itemYearId, - path, -}: { - itemYearId: number; - path: string; -}): Promise { - if (!(await canWriteCatalogue())) - return { ok: false, error: "Catalogue write permission is required." }; - const supabase = await createClient(); - const { error } = await supabase.rpc("discard_catalogue_draft", { - p_item_year_id: itemYearId, - }); - if (error) return { ok: false, error: error.message }; - revalidateRecord(path); - return { - ok: true, - message: "Draft discarded. The snapshot stays in history.", - }; -} diff --git a/apps/web/lib/coursemap/admin-catalogue-record.ts b/apps/web/lib/coursemap/admin-catalogue-record.ts index 335ba687..7ad02bc1 100644 --- a/apps/web/lib/coursemap/admin-catalogue-record.ts +++ b/apps/web/lib/coursemap/admin-catalogue-record.ts @@ -1,7 +1,7 @@ import "server-only"; import { withImportDatabaseClient } from "@/lib/catalogue-import/import-store"; -import { readSnapshotWrite } from "@/lib/catalogue-import/snapshot-read"; -import type { CatalogueSnapshotWrite } from "@/lib/catalogue-import/snapshot-write"; +import { readVersionContent } from "@/lib/catalogue-import/version-content"; +import type { CatalogueContent } from "@/lib/catalogue/content"; import { createClient } from "@/lib/supabase/server"; import type { Json } from "@/types/database"; import type { CatalogueKind } from "./catalogue-kinds"; @@ -33,18 +33,18 @@ export type ReviewTarget = { createdAt: string; completedAt: string | null; appliedAt: string | null; - baselineSnapshotId: number | null; - candidateSnapshotId: number | null; + baselineVersionId: number | null; + candidateVersionId: number | null; entries: ReviewEntry[]; }; -export type CatalogueRecordSnapshot = { +export type CatalogueVersion = { id: number; publicId: string; origin: string; createdAt: string; sealedAt: string | null; - basedOnSnapshotId: number | null; + basedOnVersionId: number | null; importTargetId: string | null; contentHash: string; }; @@ -53,41 +53,43 @@ export type CatalogueRecord = { kind: CatalogueKind; code: string; academicYear: number; - itemId: number; - itemYearId: number; - itemYearPublicId: string; + codeId: number; + recordId: number; + recordPublicId: string; title: string; - draftSnapshotId: number | null; - publishedSnapshotId: number | null; + currentVersionId: number | null; + publishedVersionId: number | null; archivedAt: string | null; publishBlockers: string[]; - snapshots: CatalogueRecordSnapshot[]; + versions: CatalogueVersion[]; publications: Array<{ - snapshotId: number | null; + versionId: number; publishedAt: string; publishedBy: string | null; + unpublishedAt: string | null; + unpublishedBy: string | null; }>; reviews: ReviewTarget[]; }; -async function snapshotTitle( +async function versionTitle( supabase: Awaited>, kind: CatalogueKind, - snapshotId: number | null, + versionId: number | null, ) { - if (!snapshotId) return null; + if (!versionId) return null; if (kind === "course") { const { data } = await supabase - .from("course_snapshot_details") + .from("course_version_details") .select("title") - .eq("snapshot_id", snapshotId) + .eq("version_id", versionId) .maybeSingle(); return data?.title ?? null; } const { data } = await supabase - .from("structure_snapshot_details") + .from("structure_version_details") .select("name") - .eq("snapshot_id", snapshotId) + .eq("version_id", versionId) .maybeSingle(); return data?.name ?? null; } @@ -104,55 +106,61 @@ export async function loadCatalogueRecord({ }): Promise { const supabase = await createClient(); const { data: itemYear, error } = await supabase - .from("catalogue_item_years") + .from("catalogue_records") .select( - "id,public_id,item_id,draft_snapshot_id,published_snapshot_id,archived_at,catalogue_items!inner(code,kind),academic_years!inner(year)", + "id,public_id,code_id,published_version_id,archived_at,catalogue_codes!inner(code,kind),academic_years!inner(year)", ) .eq("kind", kind) - .eq("catalogue_items.code", code.toUpperCase()) + .eq("catalogue_codes.code", code.toUpperCase()) .eq("academic_years.year", academicYear) .maybeSingle(); if (error) throw error; if (!itemYear) return null; - const [ - snapshotsResult, - publicationsResult, - targetsResult, - blockersResult, - title, - ] = await Promise.all([ - supabase - .from("catalogue_snapshots") - .select( - "id,public_id,origin,created_at,sealed_at,based_on_snapshot_id,import_target_id,content_hash", - ) - .eq("item_year_id", itemYear.id) - .order("created_at", { ascending: false }), - supabase - .from("catalogue_publications") - .select("snapshot_id,published_at,published_by") - .eq("item_year_id", itemYear.id) - .order("published_at", { ascending: false }), - supabase - .from("catalogue_import_targets") - .select( - "id,run_id,status,change_kind,created_at,completed_at,applied_at,baseline_snapshot_id,candidate_snapshot_id,catalogue_import_runs!inner(run_number)", - ) - .eq("item_year_id", itemYear.id) - .order("created_at", { ascending: false }), - supabase.rpc("catalogue_publish_blockers", { p_item_year_id: itemYear.id }), - snapshotTitle( - supabase, - kind, - itemYear.draft_snapshot_id ?? itemYear.published_snapshot_id, - ), - ]); - if (snapshotsResult.error) throw snapshotsResult.error; + const [versionsResult, publicationsResult, targetsResult, blockersResult] = + await Promise.all([ + supabase + .from("catalogue_versions") + .select( + "id,public_id,origin,created_at,sealed_at,based_on_version_id,import_target_id,content_hash", + ) + .eq("record_id", itemYear.id) + .order("created_at", { ascending: false }), + supabase + .from("catalogue_publications") + .select( + "version_id,published_at,published_by,unpublished_at,unpublished_by", + ) + .eq("record_id", itemYear.id) + .order("published_at", { ascending: false }), + supabase + .from("catalogue_import_targets") + .select( + "id,run_id,status,change_kind,created_at,completed_at,applied_at,applied_version_id,baseline_version_id,candidate_version_id,catalogue_import_runs!inner(run_number)", + ) + .eq("record_id", itemYear.id) + .order("created_at", { ascending: false }), + supabase.rpc("catalogue_publish_blockers", { p_record_id: itemYear.id }), + ]); + if (versionsResult.error) throw versionsResult.error; if (publicationsResult.error) throw publicationsResult.error; if (targetsResult.error) throw targetsResult.error; if (blockersResult.error) throw blockersResult.error; + const appliedVersionIds = new Set( + (targetsResult.data ?? []).flatMap((target) => + target.applied_version_id === null ? [] : [target.applied_version_id], + ), + ); + const currentVersionId = + (versionsResult.data ?? []).find( + (version) => + version.sealed_at !== null && + (version.import_target_id === null || + appliedVersionIds.has(version.id)), + )?.id ?? null; + const title = await versionTitle(supabase, kind, currentVersionId); + const targetIds = (targetsResult.data ?? []).map((target) => target.id); const { data: entries, error: entriesError } = targetIds.length ? await supabase @@ -186,30 +194,32 @@ export async function loadCatalogueRecord({ return { kind, - code: itemYear.catalogue_items.code, + code: itemYear.catalogue_codes.code, academicYear, - itemId: itemYear.item_id, - itemYearId: itemYear.id, - itemYearPublicId: itemYear.public_id, - title: title ?? itemYear.catalogue_items.code, - draftSnapshotId: itemYear.draft_snapshot_id, - publishedSnapshotId: itemYear.published_snapshot_id, + codeId: itemYear.code_id, + recordId: itemYear.id, + recordPublicId: itemYear.public_id, + title: title ?? itemYear.catalogue_codes.code, + currentVersionId, + publishedVersionId: itemYear.published_version_id, archivedAt: itemYear.archived_at, publishBlockers: (blockersResult.data as string[] | null) ?? [], - snapshots: (snapshotsResult.data ?? []).map((snapshot) => ({ - id: snapshot.id, - publicId: snapshot.public_id, - origin: snapshot.origin, - createdAt: snapshot.created_at, - sealedAt: snapshot.sealed_at, - basedOnSnapshotId: snapshot.based_on_snapshot_id, - importTargetId: snapshot.import_target_id, - contentHash: snapshot.content_hash, + versions: (versionsResult.data ?? []).map((version) => ({ + id: version.id, + publicId: version.public_id, + origin: version.origin, + createdAt: version.created_at, + sealedAt: version.sealed_at, + basedOnVersionId: version.based_on_version_id, + importTargetId: version.import_target_id, + contentHash: version.content_hash, })), publications: (publicationsResult.data ?? []).map((publication) => ({ - snapshotId: publication.snapshot_id, + versionId: publication.version_id, publishedAt: publication.published_at, publishedBy: publication.published_by, + unpublishedAt: publication.unpublished_at, + unpublishedBy: publication.unpublished_by, })), reviews: (targetsResult.data ?? []).map((target) => ({ id: target.id, @@ -220,8 +230,8 @@ export async function loadCatalogueRecord({ createdAt: target.created_at, completedAt: target.completed_at, appliedAt: target.applied_at, - baselineSnapshotId: target.baseline_snapshot_id, - candidateSnapshotId: target.candidate_snapshot_id, + baselineVersionId: target.baseline_version_id, + candidateVersionId: target.candidate_version_id, entries: entriesByTarget.get(target.id) ?? [], })), }; @@ -230,16 +240,19 @@ export async function loadCatalogueRecord({ /** The editable content of a snapshot, read through the import connection. */ export async function loadSnapshotWrite( snapshotId: number, -): Promise { - return withImportDatabaseClient((sql) => readSnapshotWrite(sql, snapshotId)); +): Promise { + return withImportDatabaseClient((sql) => readVersionContent(sql, snapshotId)); } /** The student-facing course details for a snapshot, or null for structures. */ export async function loadSnapshotCoursePreview(snapshotId: number) { const supabase = await createClient(); - const { data, error } = await supabase.rpc("admin_snapshot_projection", { - p_snapshot_id: snapshotId, - }); + const { data, error } = await supabase.rpc( + "admin_catalogue_version_projection", + { + p_version_id: snapshotId, + }, + ); if (error) throw error; if (data === null) return null; return courseFromSnapshotProjection(data as Json, snapshotId); diff --git a/apps/web/lib/coursemap/admin-catalogue.ts b/apps/web/lib/coursemap/admin-catalogue.ts index 00df75a8..614eea2d 100644 --- a/apps/web/lib/coursemap/admin-catalogue.ts +++ b/apps/web/lib/coursemap/admin-catalogue.ts @@ -95,7 +95,7 @@ export async function defaultCatalogueYear( ) { const supabase = await createClient(); const { data } = await supabase - .from("catalogue_item_years") + .from("catalogue_records") .select("academic_years(year)") .eq("kind", kind) .order("academic_year_id", { ascending: false }) @@ -184,7 +184,7 @@ export async function loadCatalogueDirectoryPage({ readAllRows((from, to) => supabase .from("catalogue_directory_entries") - .select("code,title,summary,item_id") + .select("code,title,summary,code_id") .eq("academic_year_id", yearRow.id) .eq("kind", kind) .eq("is_current", true) @@ -193,13 +193,11 @@ export async function loadCatalogueDirectoryPage({ ), readAllRows((from, to) => supabase - .from("catalogue_item_years") - .select( - "item_id,public_id,draft_snapshot_id,published_snapshot_id,archived_at", - ) + .from("catalogue_records") + .select("code_id,public_id,published_version_id,archived_at") .eq("academic_year_id", yearRow.id) .eq("kind", kind) - .order("item_id") + .order("code_id") .range(from, to), ), // Newest first so the first target seen per item is its latest; id breaks @@ -208,7 +206,7 @@ export async function loadCatalogueDirectoryPage({ supabase .from("catalogue_import_targets") .select( - "id,run_id,item_id,status,change_kind,error_message,completed_at,created_at", + "id,run_id,code_id,status,change_kind,error_message,completed_at,created_at,applied_version_id", ) .eq("academic_year_id", yearRow.id) .eq("kind", kind) @@ -223,28 +221,28 @@ export async function loadCatalogueDirectoryPage({ if (targetsResult.error) throw targetsResult.error; const itemYearByItem = new Map( - (itemYearsResult.data ?? []).map((row) => [row.item_id, row]), + (itemYearsResult.data ?? []).map((row) => [row.code_id, row]), ); const latestTargetByItem = new Map< number, (typeof targetsResult.data)[number] >(); for (const target of targetsResult.data ?? []) { - if (!latestTargetByItem.has(target.item_id)) { - latestTargetByItem.set(target.item_id, target); + if (!latestTargetByItem.has(target.code_id)) { + latestTargetByItem.set(target.code_id, target); } } // Items imported directly (without a directory row) still appear so the // administrator can see everything the year holds. const entryCodes = new Set(entriesResult.data.map((row) => row.code)); - const entryItemIds = new Set(entriesResult.data.map((row) => row.item_id)); + const entryItemIds = new Set(entriesResult.data.map((row) => row.code_id)); const extraItemIds = [...itemYearByItem.keys()].filter( (itemId) => !entryItemIds.has(itemId), ); const { data: extraItems } = extraItemIds.length ? await supabase - .from("catalogue_items") + .from("catalogue_codes") .select("id,code") .in("id", extraItemIds) : { data: [] as Array<{ id: number; code: string }> }; @@ -254,7 +252,7 @@ export async function loadCatalogueDirectoryPage({ code: entry.code, title: entry.title, summary: (entry.summary ?? {}) as Record, - itemId: entry.item_id, + itemId: entry.code_id, })), ...(extraItems ?? []) .filter((item) => !entryCodes.has(item.code)) @@ -269,14 +267,17 @@ export async function loadCatalogueDirectoryPage({ const itemYear = itemId === null ? undefined : itemYearByItem.get(itemId); const latest = itemId === null ? undefined : latestTargetByItem.get(itemId); - const hasDraft = Boolean(itemYear?.draft_snapshot_id); + const hasDraft = Boolean( + latest?.applied_version_id && + latest.applied_version_id !== itemYear?.published_version_id, + ); const isPublished = - Boolean(itemYear?.published_snapshot_id) && !itemYear?.archived_at; + Boolean(itemYear?.published_version_id) && !itemYear?.archived_at; return { code, title, summary, - itemYearPublicId: itemYear?.public_id ?? null, + recordPublicId: itemYear?.public_id ?? null, hasDraft, isPublished, workflow: workflowFor({ @@ -389,10 +390,10 @@ function runRow(run: RunRow): ImportRunRow { // The year is taken from the target's own column and resolved through the // academic year table, rather than reached by a nested embed through the run: -// PostgREST resolves `catalogue_import_targets -> catalogue_item_years` by two +// PostgREST resolves `catalogue_import_targets -> catalogue_records` by two // foreign keys of the same name, so the embedded shape is not typed. const RECORD_COLUMNS = - "id,code,academic_year_id,status,change_kind,attempt_count,error_code,error_message,applied_snapshot_id,created_at,completed_at,run_id,directory_entry_id,item_year_id"; + "id,code,academic_year_id,status,change_kind,attempt_count,error_code,error_message,applied_version_id,created_at,completed_at,run_id,directory_entry_id,record_id"; type RecordRow = { id: string; @@ -403,12 +404,12 @@ type RecordRow = { attempt_count: number; error_code: string | null; error_message: string | null; - applied_snapshot_id: number | null; + applied_version_id: number | null; created_at: string; completed_at: string | null; run_id: string; directory_entry_id: number | null; - item_year_id: number; + record_id: number; }; /** @@ -542,14 +543,14 @@ export async function loadCatalogueImportRecords({ // The labels a row needs are resolved by id over the page rather than by // embedding them in the select above. One page is twenty-five rows, so this // is four small reads, and it keeps the ambiguous embeds out: PostgREST - // reaches `catalogue_item_years` from a target through two foreign keys of + // reaches `catalogue_records` from a target through two foreign keys of // the same name, which it will not resolve and cannot type. const distinct = (values: Value[]) => [...new Set(values)]; const entryIds = distinct( rows.map((row) => row.directory_entry_id).filter((id) => id !== null), ); const yearIds = distinct(rows.map((row) => row.academic_year_id)); - const itemYearIds = distinct(rows.map((row) => row.item_year_id)); + const recordIds = distinct(rows.map((row) => row.record_id)); // The recent runs are already loaded, so only a row from an older run than // the list offers costs a read. const olderRunIds = distinct(rows.map((row) => row.run_id)).filter( @@ -566,11 +567,11 @@ export async function loadCatalogueImportRecords({ yearIds.length ? supabase.from("academic_years").select("id,year").in("id", yearIds) : null, - itemYearIds.length + recordIds.length ? supabase - .from("catalogue_item_years") + .from("catalogue_records") .select("id,public_id") - .in("id", itemYearIds) + .in("id", recordIds) : null, olderRunIds.length ? supabase @@ -614,8 +615,8 @@ export async function loadCatalogueImportRecords({ attemptCount: row.attempt_count, errorCode: row.error_code, errorMessage: row.error_message, - appliedSnapshotId: row.applied_snapshot_id, - itemYearPublicId: publicIdById.get(row.item_year_id) ?? null, + appliedVersionId: row.applied_version_id, + recordPublicId: publicIdById.get(row.record_id) ?? null, createdAt: row.created_at, completedAt: row.completed_at, runId: row.run_id, @@ -737,12 +738,18 @@ export async function loadImportTargetDetail( export async function loadAdminCatalogueSummary(): Promise { const supabase = await createClient(); - const [{ data: itemYears }, { data: items }] = await Promise.all([ - supabase - .from("catalogue_item_years") - .select("kind,draft_snapshot_id,published_snapshot_id,archived_at"), - supabase.from("catalogue_items").select("kind"), - ]); + const [{ data: itemYears }, { data: items }, { data: appliedTargets }] = + await Promise.all([ + supabase + .from("catalogue_records") + .select("id,kind,published_version_id,archived_at"), + supabase.from("catalogue_codes").select("kind"), + supabase + .from("catalogue_import_targets") + .select("record_id,applied_version_id,created_at") + .not("applied_version_id", "is", null) + .order("created_at", { ascending: false }), + ]); const summary = Object.fromEntries( (Object.keys(CATALOGUE_KIND_LABELS) as CatalogueKind[]).map((kind) => [ kind, @@ -751,10 +758,20 @@ export async function loadAdminCatalogueSummary(): Promise(); + for (const target of appliedTargets ?? []) { + if ( + !latestAppliedByRecord.has(target.record_id) && + target.applied_version_id + ) + latestAppliedByRecord.set(target.record_id, target.applied_version_id); + } for (const year of itemYears ?? []) { const bucket = summary[year.kind as CatalogueKind]; - if (year.published_snapshot_id && !year.archived_at) bucket.published += 1; - if (year.draft_snapshot_id) bucket.drafts += 1; + if (year.published_version_id && !year.archived_at) bucket.published += 1; + const appliedVersionId = latestAppliedByRecord.get(year.id); + if (appliedVersionId && appliedVersionId !== year.published_version_id) + bucket.drafts += 1; } return summary; } diff --git a/apps/web/lib/coursemap/catalogue-kinds.ts b/apps/web/lib/coursemap/catalogue-kinds.ts index b74ed6fd..3b166494 100644 --- a/apps/web/lib/coursemap/catalogue-kinds.ts +++ b/apps/web/lib/coursemap/catalogue-kinds.ts @@ -1,4 +1,4 @@ -import type { CatalogueKind } from "@/lib/catalogue-import/snapshot-write"; +import type { CatalogueKind } from "@/lib/catalogue/content"; export type { CatalogueKind }; @@ -39,7 +39,7 @@ export type CatalogueDirectoryRecord = { code: string; title: string | null; summary: Record; - itemYearPublicId: string | null; + recordPublicId: string | null; hasDraft: boolean; isPublished: boolean; workflow: DirectoryWorkflowStatus; @@ -94,9 +94,9 @@ export type ImportRunSummary = { attemptCount: number; errorCode: string | null; errorMessage: string | null; - candidateSnapshotId: number | null; - appliedSnapshotId: number | null; - itemYearPublicId: string | null; + candidateVersionId: number | null; + appliedVersionId: number | null; + recordPublicId: string | null; }>; }; @@ -268,8 +268,8 @@ export type ImportRecordRow = { attemptCount: number; errorCode: string | null; errorMessage: string | null; - appliedSnapshotId: number | null; - itemYearPublicId: string | null; + appliedVersionId: number | null; + recordPublicId: string | null; createdAt: string; completedAt: string | null; runId: string; diff --git a/apps/web/lib/coursemap/onboarding-catalogue.ts b/apps/web/lib/coursemap/onboarding-catalogue.ts index c8fdf5c1..ca0f598a 100644 --- a/apps/web/lib/coursemap/onboarding-catalogue.ts +++ b/apps/web/lib/coursemap/onboarding-catalogue.ts @@ -43,19 +43,19 @@ export type OnboardingCatalogue = { export async function loadOnboardingCatalogue(): Promise { const supabase = createPublicClient(); const { data: structureYears, error: structureYearsError } = await supabase - .from("catalogue_item_years") - .select("academic_year_id,published_snapshot_id,item_id") + .from("catalogue_records") + .select("academic_year_id,published_version_id,code_id") .neq("kind", "course") .is("archived_at", null) - .not("published_snapshot_id", "is", null); + .not("published_version_id", "is", null); if (structureYearsError) throw structureYearsError; const publishedYears = (structureYears ?? []).filter( ( row, ): row is typeof row & { - published_snapshot_id: number; - } => row.published_snapshot_id !== null, + published_version_id: number; + } => row.published_version_id !== null, ); if (publishedYears.length === 0) { return { @@ -70,8 +70,8 @@ export async function loadOnboardingCatalogue(): Promise { const academicYearIds = [ ...new Set(publishedYears.map((row) => row.academic_year_id)), ]; - const structureIds = [...new Set(publishedYears.map((row) => row.item_id))]; - const snapshotIds = publishedYears.map((row) => row.published_snapshot_id); + const structureIds = [...new Set(publishedYears.map((row) => row.code_id))]; + const snapshotIds = publishedYears.map((row) => row.published_version_id); const [ yearsResult, structuresResult, @@ -86,26 +86,26 @@ export async function loadOnboardingCatalogue(): Promise { .in("id", academicYearIds) .order("year", { ascending: false }), supabase - .from("catalogue_items") + .from("catalogue_codes") .select("code,id,kind") .in("id", structureIds), supabase - .from("structure_snapshot_details") - .select("description,duration_years,snapshot_id,name,units") - .in("snapshot_id", snapshotIds), + .from("structure_version_details") + .select("description,duration_years,version_id,name,units") + .in("version_id", snapshotIds), supabase .from("academic_structure_snapshot_relationships") - .select("relationship_kind,snapshot_id,target_code,target_kind") - .in("snapshot_id", snapshotIds), + .select("relationship_kind,version_id,target_code,target_kind") + .in("version_id", snapshotIds), supabase .from("requirement_conditions") - .select("condition_kind,id,snapshot_id,structure_kind") - .in("snapshot_id", snapshotIds) + .select("condition_kind,id,version_id,structure_kind") + .in("version_id", snapshotIds) .eq("condition_kind", "structure_set"), supabase .from("requirement_condition_options") - .select("code,condition_id,kind,snapshot_id") - .in("snapshot_id", snapshotIds) + .select("code,condition_id,kind,version_id") + .in("version_id", snapshotIds) .neq("kind", "course"), ]); const error = [ @@ -125,12 +125,12 @@ export async function loadOnboardingCatalogue(): Promise { (structuresResult.data ?? []).map((structure) => [structure.id, structure]), ); const publishedYearBySnapshotId = new Map( - publishedYears.map((row) => [row.published_snapshot_id, row]), + publishedYears.map((row) => [row.published_version_id, row]), ); const programmeSnapshotIds = new Set( publishedYears.flatMap((row) => { - const identity = structureById.get(row.item_id); - return identity?.kind === "programme" ? [row.published_snapshot_id] : []; + const identity = structureById.get(row.code_id); + return identity?.kind === "programme" ? [row.published_version_id] : []; }), ); const structureCodesByProgrammeSnapshot = collectSelectableStructureCodes({ @@ -149,10 +149,10 @@ export async function loadOnboardingCatalogue(): Promise { (snapshotsResult.data ?? []) .flatMap((snapshot) => { const structureYear = publishedYearBySnapshotId.get( - snapshot.snapshot_id, + snapshot.version_id, ); const identity = structureYear - ? structureById.get(structureYear.item_id) + ? structureById.get(structureYear.code_id) : null; const academicYear = structureYear ? yearById.get(structureYear.academic_year_id) @@ -160,7 +160,7 @@ export async function loadOnboardingCatalogue(): Promise { if (!identity || !academicYear || identity.kind !== kind) return []; const selectableCodes = kind === "programme" - ? (structureCodesByProgrammeSnapshot.get(snapshot.snapshot_id) ?? + ? (structureCodesByProgrammeSnapshot.get(snapshot.version_id) ?? emptySelectableStructureCodes()) : emptySelectableStructureCodes(); return [ @@ -184,7 +184,7 @@ export async function loadOnboardingCatalogue(): Promise { const programmeYearIds = new Set( publishedYears.flatMap((row) => { - const identity = structureById.get(row.item_id); + const identity = structureById.get(row.code_id); return identity?.kind === "programme" ? [row.academic_year_id] : []; }), ); diff --git a/apps/web/lib/coursemap/plan-catalogue.ts b/apps/web/lib/coursemap/plan-catalogue.ts index 0127c1e6..f0aa9597 100644 --- a/apps/web/lib/coursemap/plan-catalogue.ts +++ b/apps/web/lib/coursemap/plan-catalogue.ts @@ -9,7 +9,7 @@ import { import type { CourseDetails } from "@/lib/coursemap/course-types"; import { getAuthViewer } from "@/lib/auth/viewer"; import { createClient } from "@/lib/supabase/server"; -import { collectPlanCatalogueCourseIds } from "@/lib/coursemap/plan-course-ids"; +import { collectPlanCatalogueRecordIds } from "@/lib/coursemap/plan-course-ids"; export type PlanCatalogue = { academicYear: number | null; @@ -104,7 +104,7 @@ type StructureSnapshotRow = { college: string | null; description: string | null; duration_years: number | null; - snapshot_id: number; + version_id: number; name: string; units: number | null; }; @@ -115,14 +115,16 @@ type RequirementConditionRow = Database["public"]["Tables"]["requirement_conditions"]["Row"]; type RequirementOptionRow = Database["public"]["Tables"]["requirement_condition_options"]["Row"]; -type PlanCourseRow = { academic_year_id: number; course_id: number }; -type PlanStructureRow = { structure_year_id: number }; -type AttemptCourseRow = { - course_id: number; - course_snapshot_id: number; +type PlanCourseRow = { catalogue_record_id: number }; +type PlanStructureRow = { catalogue_record_id: number }; +type AttemptCourseRow = { catalogue_version_id: number }; +type AttemptVersionRow = { + id: number; + record_id: number; }; -type AttemptSnapshotRow = { +type CatalogueRecordRow = { academic_year_id: number; + code_id: number; id: number; }; @@ -286,11 +288,11 @@ async function loadAcademicYearRecord( } const programmeYearsResult = await supabase - .from("catalogue_item_years") + .from("catalogue_records") .select("academic_year_id") .eq("kind", "programme") .is("archived_at", null) - .not("published_snapshot_id", "is", null); + .not("published_version_id", "is", null); if (programmeYearsResult.error) throw programmeYearsResult.error; const academicYearIds = [ ...new Set( @@ -343,12 +345,12 @@ export async function loadPublishedPlanCatalogue( .order("calendar_year") .order("sort_order"), supabase - .from("catalogue_item_years") - .select("id,published_snapshot_id,item_id") + .from("catalogue_records") + .select("id,published_version_id,code_id") .eq("academic_year_id", academicYearRecord.id) .neq("kind", "course") .is("archived_at", null) - .not("published_snapshot_id", "is", null), + .not("published_version_id", "is", null), ]); if (periodsResult.error) throw periodsResult.error; if (structureYearsResult.error) throw structureYearsResult.error; @@ -357,28 +359,28 @@ export async function loadPublishedPlanCatalogue( ( row, ): row is typeof row & { - published_snapshot_id: number; - } => row.published_snapshot_id !== null, + published_version_id: number; + } => row.published_version_id !== null, ); const structureIds = [ - ...new Set(structureYears.map((structureYear) => structureYear.item_id)), + ...new Set(structureYears.map((structureYear) => structureYear.code_id)), ]; const snapshotIds = structureYears.map( - (structureYear) => structureYear.published_snapshot_id, + (structureYear) => structureYear.published_version_id, ); const [structureIdentitiesResult, structureSnapshotsResult] = await Promise.all([ structureIds.length ? supabase - .from("catalogue_items") + .from("catalogue_codes") .select("code,id,kind") .in("id", structureIds) : Promise.resolve({ data: [], error: null }), snapshotIds.length ? supabase - .from("structure_snapshot_details") - .select("college,description,duration_years,snapshot_id,name,units") - .in("snapshot_id", snapshotIds) + .from("structure_version_details") + .select("college,description,duration_years,version_id,name,units") + .in("version_id", snapshotIds) : Promise.resolve({ data: [], error: null }), ]); const structureError = [ @@ -394,7 +396,7 @@ export async function loadPublishedPlanCatalogue( ); const snapshotsById = new Map( ((structureSnapshotsResult.data ?? []) as StructureSnapshotRow[]).map( - (snapshot) => [snapshot.snapshot_id, snapshot], + (snapshot) => [snapshot.version_id, snapshot], ), ); @@ -405,11 +407,11 @@ export async function loadPublishedPlanCatalogue( ), ); const requirementsSnapshotIds = structureYears.flatMap((structureYear) => { - const kind = identitiesById.get(structureYear.item_id)?.kind; + const kind = identitiesById.get(structureYear.code_id)?.kind; return selectedStructureYears.has(structureYear.id) && kind !== undefined && isPlanStructureKind(kind) - ? [structureYear.published_snapshot_id] + ? [structureYear.published_version_id] : []; }); const requirementsSnapshotIdSet = new Set(requirementsSnapshotIds); @@ -418,21 +420,21 @@ export async function loadPublishedPlanCatalogue( ? supabase .from("requirement_groups") .select("*") - .in("snapshot_id", requirementsSnapshotIds) + .in("version_id", requirementsSnapshotIds) .order("position") : Promise.resolve({ data: [], error: null }), requirementsSnapshotIds.length ? supabase .from("requirement_conditions") .select("*") - .in("snapshot_id", requirementsSnapshotIds) + .in("version_id", requirementsSnapshotIds) .order("position") : Promise.resolve({ data: [], error: null }), requirementsSnapshotIds.length ? supabase .from("requirement_condition_options") .select("*") - .in("snapshot_id", requirementsSnapshotIds) + .in("version_id", requirementsSnapshotIds) .order("position") : Promise.resolve({ data: [], error: null }), ]); @@ -452,13 +454,13 @@ export async function loadPublishedPlanCatalogue( for (const option of requirementOptions) { if (option.kind !== "course") continue; const codes = - courseCodesBySnapshotId.get(option.snapshot_id) ?? new Set(); + courseCodesBySnapshotId.get(option.version_id) ?? new Set(); codes.add(option.code); - courseCodesBySnapshotId.set(option.snapshot_id, codes); + courseCodesBySnapshotId.set(option.version_id, codes); } const degrees = structureYears.flatMap((structureYear) => { - const identity = identitiesById.get(structureYear.item_id); - const snapshot = snapshotsById.get(structureYear.published_snapshot_id); + const identity = identitiesById.get(structureYear.code_id); + const snapshot = snapshotsById.get(structureYear.published_version_id); if (!identity || !snapshot || identity.kind !== "programme") return []; return [ { @@ -475,8 +477,8 @@ export async function loadPublishedPlanCatalogue( ]; }); const structures = structureYears.flatMap((structureYear) => { - const identity = identitiesById.get(structureYear.item_id); - const snapshot = snapshotsById.get(structureYear.published_snapshot_id); + const identity = identitiesById.get(structureYear.code_id); + const snapshot = snapshotsById.get(structureYear.published_version_id); if (!identity || !snapshot || !isPlanStructureKind(identity.kind)) return []; return [ @@ -488,28 +490,28 @@ export async function loadPublishedPlanCatalogue( ]; }); const structureRequirements = structureYears.flatMap((structureYear) => { - const identity = identitiesById.get(structureYear.item_id); - const snapshot = snapshotsById.get(structureYear.published_snapshot_id); + const identity = identitiesById.get(structureYear.code_id); + const snapshot = snapshotsById.get(structureYear.published_version_id); if ( !identity || !snapshot || - !requirementsSnapshotIdSet.has(structureYear.published_snapshot_id) || + !requirementsSnapshotIdSet.has(structureYear.published_version_id) || !isPlanStructureKind(identity.kind) ) { return []; } - const snapshotId = structureYear.published_snapshot_id; + const snapshotId = structureYear.published_version_id; return [ { root: buildAcademicStructureRequirementTree({ groups: requirementGroups.filter( - (group) => group.snapshot_id === snapshotId, + (group) => group.version_id === snapshotId, ), conditions: requirementConditions.filter( - (condition) => condition.snapshot_id === snapshotId, + (condition) => condition.version_id === snapshotId, ), options: requirementOptions.filter( - (option) => option.snapshot_id === snapshotId, + (option) => option.version_id === snapshotId, ), }), snapshotId, @@ -521,7 +523,7 @@ export async function loadPublishedPlanCatalogue( unmodelled: requirementConditions .filter( (condition) => - condition.snapshot_id === snapshotId && + condition.version_id === snapshotId && condition.condition_kind === "other" && condition.free_text !== null, ) @@ -534,8 +536,8 @@ export async function loadPublishedPlanCatalogue( ]; }); const majors = structureYears.flatMap((structureYear) => { - const identity = identitiesById.get(structureYear.item_id); - const snapshot = snapshotsById.get(structureYear.published_snapshot_id); + const identity = identitiesById.get(structureYear.code_id); + const snapshot = snapshotsById.get(structureYear.published_version_id); if (!identity || !snapshot || identity.kind !== "major") return []; return [ { @@ -545,9 +547,8 @@ export async function loadPublishedPlanCatalogue( colour: "zinc", description: snapshot.description ?? "", courseCodes: [ - ...(courseCodesBySnapshotId.get( - structureYear.published_snapshot_id, - ) ?? []), + ...(courseCodesBySnapshotId.get(structureYear.published_version_id) ?? + []), ].sort(), } satisfies Major, ]; @@ -612,15 +613,15 @@ export async function loadCurrentUserPlanCatalogue(): Promise { const [itemsResult, attemptsResult, structuresResult] = await Promise.all([ supabase .from("plan_items") - .select("course_id,academic_year_id") + .select("catalogue_record_id") .eq("plan_id", plan.id), supabase .from("course_attempts") - .select("course_id,course_snapshot_id") + .select("catalogue_version_id") .eq("owner_id", viewer.id), supabase .from("plan_structures") - .select("structure_year_id") + .select("catalogue_record_id") .eq("plan_id", plan.id), ]); if (itemsResult.error || attemptsResult.error || structuresResult.error) { @@ -633,41 +634,50 @@ export async function loadCurrentUserPlanCatalogue(): Promise { []) as unknown as AttemptCourseRow[]; const planStructures = (structuresResult.data ?? []) as unknown as PlanStructureRow[]; - const courseIds = collectPlanCatalogueCourseIds(planItems, courseAttempts); - const academicYearIds = [ - ...new Set(planItems.map((item) => item.academic_year_id)), - ]; - const snapshotIds = [ - ...new Set(courseAttempts.map((attempt) => attempt.course_snapshot_id)), + const versionIds = [ + ...new Set(courseAttempts.map((attempt) => attempt.catalogue_version_id)), ]; - const [coursesResult, snapshotsResult] = await Promise.all([ - courseIds.length - ? supabase.from("catalogue_items").select("id,code").in("id", courseIds) - : Promise.resolve({ data: [], error: null }), - snapshotIds.length - ? supabase - .from("catalogue_snapshots") - .select("id,academic_year_id") - .in("id", snapshotIds) - : Promise.resolve({ data: [], error: null }), - ]); - if (coursesResult.error || snapshotsResult.error) { + const versionsResult = versionIds.length + ? await supabase + .from("catalogue_versions") + .select("id,record_id") + .in("id", versionIds) + : { data: [], error: null }; + if (versionsResult.error) { return loadPublishedPlanCatalogue(year.year); } - const attemptSnapshots = (snapshotsResult.data ?? []) as AttemptSnapshotRow[]; + const attemptVersions = (versionsResult.data ?? []) as AttemptVersionRow[]; + const recordIds = collectPlanCatalogueRecordIds( + planItems, + attemptVersions.map((version) => ({ + catalogue_record_id: version.record_id, + })), + ); + const recordsResult = recordIds.length + ? await supabase + .from("catalogue_records") + .select("id,code_id,academic_year_id") + .in("id", recordIds) + : { data: [], error: null }; + if (recordsResult.error) return loadPublishedPlanCatalogue(year.year); + const records = (recordsResult.data ?? []) as CatalogueRecordRow[]; + const courseIds = [...new Set(records.map((record) => record.code_id))]; const allAcademicYearIds = [ - ...new Set([ - ...academicYearIds, - ...attemptSnapshots.map((snapshot) => snapshot.academic_year_id), - ]), + ...new Set(records.map((record) => record.academic_year_id)), ]; + const coursesResult = courseIds.length + ? await supabase + .from("catalogue_codes") + .select("id,code") + .in("id", courseIds) + : { data: [], error: null }; const academicYearsResult = allAcademicYearIds.length ? await supabase .from("academic_years") .select("id,year") .in("id", allAcademicYearIds) : { data: [], error: null }; - if (academicYearsResult.error) { + if (coursesResult.error || academicYearsResult.error) { return loadPublishedPlanCatalogue(year.year); } const codeByCourseId = new Map( @@ -679,51 +689,52 @@ export async function loadCurrentUserPlanCatalogue(): Promise { academicYear.year, ]), ); - const academicYearIdBySnapshotId = new Map( - attemptSnapshots.map((snapshot) => [ - snapshot.id, - snapshot.academic_year_id, - ]), + const recordById = new Map(records.map((record) => [record.id, record])); + const recordIdByVersionId = new Map( + attemptVersions.map((version) => [version.id, version.record_id]), ); const selections = [ ...planItems.flatMap((item) => { - const code = codeByCourseId.get(item.course_id); - const academicYear = yearByAcademicYearId.get(item.academic_year_id); + const record = recordById.get(item.catalogue_record_id); + const code = record ? codeByCourseId.get(record.code_id) : undefined; + const academicYear = record + ? yearByAcademicYearId.get(record.academic_year_id) + : undefined; return code && academicYear ? [{ code, year: academicYear }] : []; }), ...courseAttempts.flatMap((attempt) => { - const code = codeByCourseId.get(attempt.course_id); - const academicYear = yearByAcademicYearId.get( - academicYearIdBySnapshotId.get(attempt.course_snapshot_id) ?? -1, + const record = recordById.get( + recordIdByVersionId.get(attempt.catalogue_version_id) ?? -1, ); + const code = record ? codeByCourseId.get(record.code_id) : undefined; + const academicYear = record + ? yearByAcademicYearId.get(record.academic_year_id) + : undefined; return code && academicYear ? [{ code, year: academicYear }] : []; }), ]; const catalogue = await loadPublishedPlanCatalogue( year.year, selections, - planStructures.map((structure) => structure.structure_year_id), + planStructures.map((structure) => structure.catalogue_record_id), ); - const publishedSnapshotIds = new Set( + const publishedVersionIds = new Set( catalogue.courses.flatMap((course) => course.snapshotId === undefined ? [] : [course.snapshotId], ), ); - const historicalSnapshotIds = snapshotIds.filter( - (snapshotId) => !publishedSnapshotIds.has(snapshotId), + const historicalSnapshotIds = versionIds.filter( + (snapshotId) => !publishedVersionIds.has(snapshotId), ); const projectionsResult = historicalSnapshotIds.length - ? await supabase.rpc("current_user_course_attempt_snapshot_projections", { - p_snapshot_ids: historicalSnapshotIds, + ? await supabase.rpc("current_user_course_attempt_version_projections", { + p_version_ids: historicalSnapshotIds, }) : { data: [], error: null }; if (projectionsResult.error) return catalogue; const snapshotCourses = (projectionsResult.data ?? []).flatMap((row) => { - const course = courseFromSnapshotProjection( - row.projection, - row.snapshot_id, - ); + const course = courseFromSnapshotProjection(row.projection, row.version_id); return course ? [planCourseFromDetails(course)] : []; }); return { ...catalogue, snapshotCourses }; diff --git a/apps/web/lib/coursemap/plan-course-ids.ts b/apps/web/lib/coursemap/plan-course-ids.ts index 0a3a9608..803361ab 100644 --- a/apps/web/lib/coursemap/plan-course-ids.ts +++ b/apps/web/lib/coursemap/plan-course-ids.ts @@ -1,12 +1,14 @@ -type CourseIdRow = { course_id: number }; +type CatalogueRecordIdRow = { catalogue_record_id: number }; -export function collectPlanCatalogueCourseIds( - planItems: readonly CourseIdRow[], - courseAttempts: readonly CourseIdRow[], +export function collectPlanCatalogueRecordIds( + planItems: readonly CatalogueRecordIdRow[], + attemptedVersions: readonly CatalogueRecordIdRow[], ) { return [ ...new Set( - [...planItems, ...courseAttempts].map((record) => record.course_id), + [...planItems, ...attemptedVersions].map( + (record) => record.catalogue_record_id, + ), ), ]; } diff --git a/apps/web/lib/coursemap/programme-structure-options.ts b/apps/web/lib/coursemap/programme-structure-options.ts index 9a1afd4d..8ee0baa0 100644 --- a/apps/web/lib/coursemap/programme-structure-options.ts +++ b/apps/web/lib/coursemap/programme-structure-options.ts @@ -9,7 +9,7 @@ export type SelectableStructureKind = export type ProgrammeStructureRelationship = { relationship_kind: string; - snapshot_id: number; + version_id: number; target_code: string; target_kind: string; }; @@ -17,7 +17,7 @@ export type ProgrammeStructureRelationship = { export type ProgrammeStructureRequirementCondition = { condition_kind: string; id: number; - snapshot_id: number; + version_id: number; structure_kind: string | null; }; @@ -25,7 +25,7 @@ export type ProgrammeStructureRequirementOption = { code: string; condition_id: number; kind: string; - snapshot_id: number; + version_id: number; }; export type SelectableStructureCodes = Record< @@ -87,7 +87,7 @@ export function collectSelectableStructureCodes({ relationship.relationship_kind === "option") ) { addCode( - relationship.snapshot_id, + relationship.version_id, relationship.target_kind, relationship.target_code, ); @@ -102,7 +102,7 @@ export function collectSelectableStructureCodes({ >(); for (const condition of requirementConditions) { if ( - programmeSnapshotIds.has(condition.snapshot_id) && + programmeSnapshotIds.has(condition.version_id) && condition.condition_kind === "structure_set" && isSelectableStructureKind(condition.structure_kind) ) { @@ -117,10 +117,10 @@ export function collectSelectableStructureCodes({ const condition = structureListConditions.get(option.condition_id); if ( condition && - condition.snapshot_id === option.snapshot_id && + condition.version_id === option.version_id && option.kind === condition.structure_kind ) { - addCode(option.snapshot_id, condition.structure_kind, option.code); + addCode(option.version_id, condition.structure_kind, option.code); } } diff --git a/apps/web/lib/coursemap/published-courses.ts b/apps/web/lib/coursemap/published-courses.ts index 72f635bc..9cd8c11b 100644 --- a/apps/web/lib/coursemap/published-courses.ts +++ b/apps/web/lib/coursemap/published-courses.ts @@ -45,7 +45,7 @@ type SnapshotListRow = { offering_status: string; prescribed_texts: string | null; school: string | null; - snapshot_id: number; + version_id: number; source_updated_at: string | null; subject_code: string | null; subject_name: string | null; @@ -56,7 +56,7 @@ type SnapshotListRow = { workload_text: string | null; }; type OfferingRow = { - snapshot_id: number; + version_id: number; delivery_mode: string | null; id: number; location: string | null; @@ -68,7 +68,7 @@ type OfferingSessionRow = { class_number: string | null; class_summary_url: string | null; course_offering_id: number; - snapshot_id: number | null; + version_id: number | null; delivery_mode: string | null; ends_on: string | null; enrol_closes_on: string | null; @@ -78,7 +78,7 @@ type OfferingSessionRow = { }; type RuleRow = { confidence: number; - snapshot_id: number | null; + version_id: number | null; id: number; review_state: string; rule_kind: string; @@ -86,15 +86,15 @@ type RuleRow = { }; type RuleReferenceRow = { rule_id: number; - item_id: number; + code_id: number; }; type RuleConditionRow = { rule_id: number; - item_id: number | null; + code_id: number | null; }; const SNAPSHOT_LIST_SELECT = - "snapshot_id,code,title,unit_value_kind,units,minimum_units,maximum_units,eftsl,level,subject_code,subject_name,school,college,academic_career,convener_text,delivery_summary,introduction,description,workload_text,workload_hours,inherent_requirements,prescribed_texts,offering_status,source_updated_at"; + "version_id,code,title,unit_value_kind,units,minimum_units,maximum_units,eftsl,level,subject_code,subject_name,school,college,academic_career,convener_text,delivery_summary,introduction,description,workload_text,workload_hours,inherent_requirements,prescribed_texts,offering_status,source_updated_at"; export type PublishedCourseFilters = { query?: string; @@ -927,12 +927,12 @@ async function loadAcademicYearOptionsUncached(): Promise< return Promise.all( ((years ?? []) as AcademicYearRow[]).map(async (year) => { const { count, error: countError } = await supabase - .from("catalogue_item_years") + .from("catalogue_records") .select("id", { count: "exact", head: true }) .eq("academic_year_id", year.id) .eq("kind", "course") .is("archived_at", null) - .not("published_snapshot_id", "is", null); + .not("published_version_id", "is", null); if (countError) throw countError; return { year: year.year, hasPublishedCourses: (count ?? 0) > 0 }; }), @@ -968,14 +968,14 @@ async function snapshotIdsForSession( ) { const { data, error } = await supabase .from("offering_sessions") - .select("snapshot_id") + .select("version_id") .eq("academic_year_id", yearId) .eq("academic_period_name", session); if (error) throw error; return [ ...new Set( (data ?? []).flatMap((row) => - row.snapshot_id === null ? [] : [row.snapshot_id], + row.version_id === null ? [] : [row.version_id], ), ), ]; @@ -986,17 +986,17 @@ async function loadListRelationships( snapshots: SnapshotListRow[], year: AcademicYearRow, ) { - const snapshotIds = snapshots.map((snapshot) => snapshot.snapshot_id); + const snapshotIds = snapshots.map((snapshot) => snapshot.version_id); if (snapshotIds.length === 0) return []; const [offeringsResult, rulesResult] = await Promise.all([ supabase .from("course_offerings") - .select("id,snapshot_id,delivery_mode,location") - .in("snapshot_id", snapshotIds), + .select("id,version_id,delivery_mode,location") + .in("version_id", snapshotIds), supabase .from("requirement_rules") - .select("id,snapshot_id,rule_kind,source_text,confidence,review_state") - .in("snapshot_id", snapshotIds) + .select("id,version_id,rule_kind,source_text,confidence,review_state") + .in("version_id", snapshotIds) .in("rule_kind", ["prerequisite", "incompatibility"]), ]); if (offeringsResult.error) throw offeringsResult.error; @@ -1011,23 +1011,23 @@ async function loadListRelationships( ? supabase .from("offering_sessions") .select( - "course_offering_id,snapshot_id,position,academic_period_code,academic_period_name,class_number,starts_on,enrol_closes_on,census_on,ends_on,delivery_mode,location,class_summary_url", + "course_offering_id,version_id,position,academic_period_code,academic_period_name,class_number,starts_on,enrol_closes_on,census_on,ends_on,delivery_mode,location,class_summary_url", ) .in("course_offering_id", offeringIds) : Promise.resolve({ data: [], error: null }), ruleIds.length ? supabase .from("requirement_item_references") - .select("rule_id,item_id") + .select("rule_id,code_id") .in("rule_id", ruleIds) : Promise.resolve({ data: [], error: null }), ruleIds.length ? supabase .from("requirement_conditions") - .select("rule_id,item_id") + .select("rule_id,code_id") .in("rule_id", ruleIds) .eq("condition_kind", "course") - .not("item_id", "is", null) + .not("code_id", "is", null) : Promise.resolve({ data: [], error: null }), ]); if (sessionsResult.error) throw sessionsResult.error; @@ -1037,28 +1037,28 @@ async function loadListRelationships( const conditions = (conditionsResult.data ?? []) as RuleConditionRow[]; const referencedIds = [ ...new Set([ - ...references.map((reference) => reference.item_id), + ...references.map((reference) => reference.code_id), ...conditions.flatMap((condition) => - condition.item_id === null ? [] : [condition.item_id], + condition.code_id === null ? [] : [condition.code_id], ), ]), ]; const [referencedResult, publishedReferencesResult] = await Promise.all([ referencedIds.length ? supabase - .from("catalogue_items") + .from("catalogue_codes") .select("id,code") .in("id", referencedIds) : Promise.resolve({ data: [], error: null }), referencedIds.length ? supabase - .from("catalogue_item_years") - .select("item_id") + .from("catalogue_records") + .select("code_id") .eq("academic_year_id", year.id) .eq("kind", "course") .is("archived_at", null) - .in("item_id", referencedIds) - .not("published_snapshot_id", "is", null) + .in("code_id", referencedIds) + .not("published_version_id", "is", null) : Promise.resolve({ data: [], error: null }), ]); if (referencedResult.error) throw referencedResult.error; @@ -1071,46 +1071,46 @@ async function loadListRelationships( ]), ); const publishedReferenceIds = new Set( - (publishedReferencesResult.data ?? []).map((row) => row.item_id), + (publishedReferencesResult.data ?? []).map((row) => row.code_id), ); const offeringBySnapshot = new Map( - offerings.map((offering) => [offering.snapshot_id, offering]), + offerings.map((offering) => [offering.version_id, offering]), ); const sessions = (sessionsResult.data ?? []) as OfferingSessionRow[]; const sessionsBySnapshot = new Map(); for (const session of sessions) { - if (session.snapshot_id === null) continue; - const existing = sessionsBySnapshot.get(session.snapshot_id) ?? []; + if (session.version_id === null) continue; + const existing = sessionsBySnapshot.get(session.version_id) ?? []; existing.push(session); - sessionsBySnapshot.set(session.snapshot_id, existing); + sessionsBySnapshot.set(session.version_id, existing); } const rulesBySnapshot = new Map(); for (const rule of rules) { - if (rule.snapshot_id === null) continue; - const existing = rulesBySnapshot.get(rule.snapshot_id) ?? []; + if (rule.version_id === null) continue; + const existing = rulesBySnapshot.get(rule.version_id) ?? []; existing.push(rule); - rulesBySnapshot.set(rule.snapshot_id, existing); + rulesBySnapshot.set(rule.version_id, existing); } const referencedIdsByRule = new Map>(); for (const reference of references) { const existing = referencedIdsByRule.get(reference.rule_id) ?? new Set(); - existing.add(reference.item_id); + existing.add(reference.code_id); referencedIdsByRule.set(reference.rule_id, existing); } for (const condition of conditions) { - if (condition.item_id === null) continue; + if (condition.code_id === null) continue; const existing = referencedIdsByRule.get(condition.rule_id) ?? new Set(); - existing.add(condition.item_id); + existing.add(condition.code_id); referencedIdsByRule.set(condition.rule_id, existing); } return snapshots.flatMap((snapshot) => { const code = snapshot.code; - const snapshotSessions = sessionsBySnapshot.get(snapshot.snapshot_id) ?? []; - const offering = offeringBySnapshot.get(snapshot.snapshot_id); - const snapshotRules = rulesBySnapshot.get(snapshot.snapshot_id) ?? []; + const snapshotSessions = sessionsBySnapshot.get(snapshot.version_id) ?? []; + const offering = offeringBySnapshot.get(snapshot.version_id); + const snapshotRules = rulesBySnapshot.get(snapshot.version_id) ?? []; const prerequisiteRules = snapshotRules.filter( (rule) => rule.rule_kind === "prerequisite", ); @@ -1163,7 +1163,7 @@ async function loadListRelationships( attributes: [], availableCourseCodes: availableCodes, code, - snapshotId: snapshot.snapshot_id, + snapshotId: snapshot.version_id, college: snapshot.college, convener: snapshot.convener_text ?? "Not listed", corequisiteText: "", @@ -1276,7 +1276,7 @@ async function loadPublishedCoursePageUncached({ snapshotsQuery = snapshotsQuery.eq("level", level * 1000); } if (sessionSnapshotIds) { - snapshotsQuery = snapshotsQuery.in("snapshot_id", sessionSnapshotIds); + snapshotsQuery = snapshotsQuery.in("version_id", sessionSnapshotIds); } if (cleanedQuery) { const pattern = `*${cleanedQuery}*`; diff --git a/apps/web/lib/coursemap/requisite-progress.ts b/apps/web/lib/coursemap/requisite-progress.ts index 4b263412..b1bf31cb 100644 --- a/apps/web/lib/coursemap/requisite-progress.ts +++ b/apps/web/lib/coursemap/requisite-progress.ts @@ -11,7 +11,7 @@ export type RequisiteCompletionSnapshot = { }; type AttemptRow = { - course_id: number; + catalogue_version_id: number; units_earned: number; }; @@ -35,18 +35,43 @@ export async function loadCurrentUserRequisiteCompletion(): Promise attempt.course_id)), - ]; + const versionIds = attemptRows.map( + (attempt) => attempt.catalogue_version_id, + ); + const { data: versions, error: versionsError } = versionIds.length + ? await supabase + .from("catalogue_versions") + .select("id,record_id") + .in("id", versionIds) + : { data: [], error: null }; + if (versionsError) throw versionsError; + const recordIds = (versions ?? []).map((version) => version.record_id); + const { data: records, error: recordsError } = recordIds.length + ? await supabase + .from("catalogue_records") + .select("id,code_id") + .in("id", recordIds) + : { data: [], error: null }; + if (recordsError) throw recordsError; + const codeIdByRecordId = new Map( + (records ?? []).map((record) => [record.id, record.code_id]), + ); + const codeIdByVersionId = new Map( + (versions ?? []).map((version) => [ + version.id, + codeIdByRecordId.get(version.record_id), + ]), + ); + const courseIds = [...new Set(codeIdByRecordId.values())]; const { data: courses, error: coursesError } = courseIds.length ? await supabase - .from("catalogue_items") + .from("catalogue_codes") .select("code,id") .in("id", courseIds) : { data: [], error: null }; @@ -60,7 +85,8 @@ export async function loadCurrentUserRequisiteCompletion(): Promise { - const code = codeByCourseId.get(attempt.course_id); + const codeId = codeIdByVersionId.get(attempt.catalogue_version_id); + const code = codeId ? codeByCourseId.get(codeId) : undefined; return code && attempt.units_earned > 0 ? [{ code, units: attempt.units_earned }] : []; @@ -98,29 +124,29 @@ async function loadEnrolledProgrammeCodes( const { data: planStructures, error: planStructuresError } = await supabase .from("plan_structures") - .select("structure_year_id") + .select("catalogue_record_id") .eq("plan_id", plan.id) .eq("role", "programme"); if (planStructuresError) return []; const structureYearIds = (planStructures ?? []).map( - (row) => row.structure_year_id, + (row) => row.catalogue_record_id, ); if (structureYearIds.length === 0) return []; const { data: structureYears, error: structureYearsError } = await supabase - .from("catalogue_item_years") - .select("item_id") + .from("catalogue_records") + .select("code_id") .in("id", structureYearIds); if (structureYearsError) return []; const structureIds = [ ...new Set( - (structureYears ?? []).map((structureYear) => structureYear.item_id), + (structureYears ?? []).map((structureYear) => structureYear.code_id), ), ]; if (structureIds.length === 0) return []; const { data: structures, error: structuresError } = await supabase - .from("catalogue_items") + .from("catalogue_codes") .select("code") .in("id", structureIds); if (structuresError) return []; diff --git a/apps/web/lib/coursemap/requisite-search-actions.ts b/apps/web/lib/coursemap/requisite-search-actions.ts index 86faf879..9b7a0a6a 100644 --- a/apps/web/lib/coursemap/requisite-search-actions.ts +++ b/apps/web/lib/coursemap/requisite-search-actions.ts @@ -16,17 +16,17 @@ export type RequisiteCourseSearchResult = { }; // The details tables join through a composite key, so PostgREST types them as -// arrays even though each snapshot has at most one details row. +// arrays even though each version has at most one details row. type ItemYearEmbed = { academic_years: { year: number } | null; published: { - course_snapshot_details: { title: string }[]; - structure_snapshot_details: { name: string }[]; + course_version_details: { title: string }[]; + structure_version_details: { name: string }[]; } | null; }; const ITEM_SEARCH_SELECT = - "code,kind,catalogue_item_years(academic_years(year),published:catalogue_snapshots!catalogue_item_years_published_snapshot_fkey(course_snapshot_details(title),structure_snapshot_details(name)))"; + "code,kind,catalogue_records(academic_years(year),published:catalogue_versions!catalogue_records_published_version_fkey(course_version_details(title),structure_version_details(name)))"; function yearsNewestFirst(itemYears: ItemYearEmbed[]) { return itemYears @@ -52,7 +52,7 @@ export async function searchRequisiteCourses( try { const supabase = await createClient(); const { data, error } = await supabase - .from("catalogue_items") + .from("catalogue_codes") .select(ITEM_SEARCH_SELECT) .eq("kind", "course") .ilike("code", `%${term}%`) @@ -64,13 +64,13 @@ export async function searchRequisiteCourses( code: item.code, subject: item.code.slice(0, 4), title: - item.catalogue_item_years + item.catalogue_records .map( (itemYear) => - itemYear.published?.course_snapshot_details[0]?.title ?? null, + itemYear.published?.course_version_details[0]?.title ?? null, ) .find((value) => value !== null) ?? null, - years: yearsNewestFirst(item.catalogue_item_years), + years: yearsNewestFirst(item.catalogue_records), })); } catch { return []; @@ -94,7 +94,7 @@ export async function searchRequisiteProgrammes( try { const supabase = await createClient(); const { data, error } = await supabase - .from("catalogue_items") + .from("catalogue_codes") .select(ITEM_SEARCH_SELECT) .neq("kind", "course") .ilike("code", `%${term}%`) @@ -106,13 +106,13 @@ export async function searchRequisiteProgrammes( code: item.code, kind: item.kind, title: - item.catalogue_item_years + item.catalogue_records .map( (itemYear) => - itemYear.published?.structure_snapshot_details[0]?.name ?? null, + itemYear.published?.structure_version_details[0]?.name ?? null, ) .find((value) => value !== null) ?? null, - years: yearsNewestFirst(item.catalogue_item_years), + years: yearsNewestFirst(item.catalogue_records), })); } catch { return []; diff --git a/apps/web/lib/coursemap/state.ts b/apps/web/lib/coursemap/state.ts index dadbc88a..9ceda18b 100644 --- a/apps/web/lib/coursemap/state.ts +++ b/apps/web/lib/coursemap/state.ts @@ -3,8 +3,7 @@ import type { AuthViewer } from "@/lib/auth/viewer"; import { createClient } from "@/lib/supabase/server"; type PlanItemRow = { - academic_year_id: number; - course_id: number; + catalogue_record_id: number; id: string; planned_calendar_year: number | null; planned_period_code: string | null; @@ -12,8 +11,7 @@ type PlanItemRow = { type CourseAttemptRow = { academic_period_id: number; - course_id: number; - course_snapshot_id: number; + catalogue_version_id: number; id: string; mark: number | null; grade?: string | null; @@ -59,7 +57,7 @@ export async function hasPrimaryPlan(viewer: AuthViewer) { export type PlanStructureSelection = { role: string; - structure_year_id: number; + catalogue_record_id: number; }; /** @@ -75,12 +73,12 @@ export function planStructureCodes( const codesFor = (role: string) => structures.flatMap((item) => { if (item.role !== role) return []; - const code = codeByYear.get(item.structure_year_id); + const code = codeByYear.get(item.catalogue_record_id); return code ? [code] : []; }); const codeForFirst = (role: string) => codeByYear.get( - structures.find((item) => item.role === role)?.structure_year_id ?? -1, + structures.find((item) => item.role === role)?.catalogue_record_id ?? -1, ); return { @@ -133,88 +131,83 @@ export async function loadCoursemapState( .maybeSingle(), supabase .from("plan_structures") - .select("role,structure_year_id") + .select("role,catalogue_record_id") .eq("plan_id", plan.id) .order("position"), supabase .from("plan_items") .select( - "id,course_id,academic_year_id,planned_calendar_year,planned_period_code,sort_order", + "id,catalogue_record_id,planned_calendar_year,planned_period_code,sort_order", ) .eq("plan_id", plan.id) .order("sort_order"), supabase .from("course_attempts") .select( - "id,course_id,course_snapshot_id,academic_period_id,status,mark,grade,units_attempted,units_earned", + "id,catalogue_version_id,academic_period_id,status,mark,grade,units_attempted,units_earned", ) .eq("owner_id", viewer.id) .order("created_at"), ]); const structures = structuresResult.data ?? []; - const structureYearIds = structures.map((item) => item.structure_year_id); - const { data: structureYears } = structureYearIds.length - ? await supabase - .from("catalogue_item_years") - .select("id,item_id") - .in("id", structureYearIds) - : { data: [] }; - const structureIds = (structureYears ?? []).map((item) => item.item_id); - const { data: structureIdentities } = structureIds.length - ? await supabase - .from("catalogue_items") - .select("id,code") - .in("id", structureIds) - : { data: [] }; - const structureCodeByYear = new Map( - (structureYears ?? []).map((structureYear) => [ - structureYear.id, - (structureIdentities ?? []).find( - (identity) => identity.id === structureYear.item_id, - )?.code, - ]), - ); - const items = (itemsResult.data ?? []) as unknown as PlanItemRow[]; const attempts = (attemptsResult.data ?? []) as unknown as CourseAttemptRow[]; - const courseIds = [ - ...new Set([ - ...items.map((item) => item.course_id), - ...attempts.map((attempt) => attempt.course_id), - ]), - ]; const periodIds = [ ...new Set(attempts.map((item) => item.academic_period_id)), ]; const snapshotIds = [ - ...new Set(attempts.map((item) => item.course_snapshot_id)), + ...new Set(attempts.map((item) => item.catalogue_version_id)), ]; - const [{ data: courseIdentities }, { data: periods }, snapshotsResult] = - await Promise.all([ - courseIds.length - ? supabase - .from("catalogue_items") - .select("id,code") - .in("id", courseIds) - : Promise.resolve({ data: [] }), - periodIds.length - ? supabase - .from("academic_periods") - .select("id,calendar_year,code") - .in("id", periodIds) - : Promise.resolve({ data: [] }), - snapshotIds.length - ? supabase - .from("catalogue_snapshots") - .select("id,academic_year_id") - .in("id", snapshotIds) - : Promise.resolve({ data: [] }), - ]); + const [{ data: periods }, snapshotsResult] = await Promise.all([ + periodIds.length + ? supabase + .from("academic_periods") + .select("id,calendar_year,code") + .in("id", periodIds) + : Promise.resolve({ data: [] }), + snapshotIds.length + ? supabase + .from("catalogue_versions") + .select("id,record_id,academic_year_id") + .in("id", snapshotIds) + : Promise.resolve({ data: [] }), + ]); + const recordIds = [ + ...new Set([ + ...structures.map((item) => item.catalogue_record_id), + ...items.map((item) => item.catalogue_record_id), + ...(snapshotsResult.data ?? []).map((version) => version.record_id), + ]), + ]; + const { data: records } = recordIds.length + ? await supabase + .from("catalogue_records") + .select("id,code_id,academic_year_id") + .in("id", recordIds) + : { data: [] }; + const codeIds = [ + ...new Set((records ?? []).map((record) => record.code_id)), + ]; + const { data: courseIdentities } = codeIds.length + ? await supabase + .from("catalogue_codes") + .select("id,code") + .in("id", codeIds) + : { data: [] }; + const recordById = new Map( + (records ?? []).map((record) => [record.id, record]), + ); + const versionRecordId = new Map( + (snapshotsResult.data ?? []).map((version) => [ + version.id, + version.record_id, + ]), + ); const academicYearIds = [ ...new Set([ - ...items.map((item) => item.academic_year_id), + ...(records ?? []).map((record) => record.academic_year_id), ...(snapshotsResult.data ?? []).map( (snapshot) => snapshot.academic_year_id, ), @@ -238,17 +231,26 @@ export async function loadCoursemapState( const courseCode = new Map( (courseIdentities ?? []).map((course) => [course.id, course.code]), ); + const structureCodeByYear = new Map( + (records ?? []).map((record) => [ + record.id, + courseCode.get(record.code_id), + ]), + ); const periodById = new Map( (periods ?? []).map((period) => [period.id, period]), ); const plannedAttempts = items.flatMap((item) => { - const code = courseCode.get(item.course_id); + const record = recordById.get(item.catalogue_record_id); + const code = record ? courseCode.get(record.code_id) : undefined; if (!code) return []; return [ { id: item.id, - academicYear: academicYearById.get(item.academic_year_id), + academicYear: record + ? academicYearById.get(record.academic_year_id) + : undefined, courseCode: code, termId: item.planned_calendar_year && item.planned_period_code @@ -259,7 +261,9 @@ export async function loadCoursemapState( ]; }); const recordedAttempts = attempts.flatMap((attempt) => { - const code = courseCode.get(attempt.course_id); + const recordId = versionRecordId.get(attempt.catalogue_version_id); + const record = recordId ? recordById.get(recordId) : undefined; + const code = record ? courseCode.get(record.code_id) : undefined; const period = periodById.get(attempt.academic_period_id); if ( !code || @@ -273,10 +277,10 @@ export async function loadCoursemapState( { id: attempt.id, academicYear: academicYearById.get( - snapshotAcademicYearId.get(attempt.course_snapshot_id) ?? -1, + snapshotAcademicYearId.get(attempt.catalogue_version_id) ?? -1, ), courseCode: code, - snapshotId: attempt.course_snapshot_id, + snapshotId: attempt.catalogue_version_id, termId: `${period.calendar_year}-${period.code.toLowerCase()}`, status: (attempt.status === "credited" ? "completed" diff --git a/apps/web/lib/coursemap/structure-snapshot-view.ts b/apps/web/lib/coursemap/structure-version-view.ts similarity index 92% rename from apps/web/lib/coursemap/structure-snapshot-view.ts rename to apps/web/lib/coursemap/structure-version-view.ts index fc277fcb..d4cd65b9 100644 --- a/apps/web/lib/coursemap/structure-snapshot-view.ts +++ b/apps/web/lib/coursemap/structure-version-view.ts @@ -1,4 +1,4 @@ -import type { CatalogueSnapshotWrite } from "@/lib/catalogue-import/snapshot-write"; +import type { CatalogueContent } from "@/lib/catalogue/content"; import { requirementTreeFromSource } from "@/lib/coursemap/requirement-write-tree"; import { REQUIREMENT_SOURCE_SECTION_KEYS, @@ -7,10 +7,10 @@ import { /** The reader's view of a structure snapshot that has not been published yet. */ export function structureDetailsFromWrite( - write: CatalogueSnapshotWrite, + write: CatalogueContent, ): StructureDetails | null { + if (write.kind === "course") return null; const structure = write.structure; - if (!structure || write.kind === "course") return null; const details = structure.details; const requirements = requirementTreeFromSource( write.requirements, diff --git a/apps/web/playwright/catalogue-review.spec.ts b/apps/web/playwright/catalogue-review.spec.ts index 6d17a891..685e8b06 100644 --- a/apps/web/playwright/catalogue-review.spec.ts +++ b/apps/web/playwright/catalogue-review.spec.ts @@ -18,17 +18,14 @@ test("administrators review, apply and publish an import candidate", async ({ let runId: string | null = null; try { const [seed] = await sql` - select item_years.id as item_year_id, item_years.academic_year_id, item_years.item_id, - item_years.published_snapshot_id, details.title - from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id - join public.course_snapshot_details as details on details.snapshot_id = item_years.published_snapshot_id - where items.code = 'COMP1110' and item_years.draft_snapshot_id is null + select item_years.id as record_id, item_years.academic_year_id, item_years.code_id, + item_years.published_version_id, details.title + from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id + join public.course_version_details as details on details.version_id = item_years.published_version_id + where items.code = 'COMP1110' `; - expect( - seed, - "the local seed publishes COMP1110 without a draft", - ).toBeTruthy(); + expect(seed, "the local seed publishes COMP1110").toBeTruthy(); const [model] = await sql`select id from public.import_models order by id limit 1`; @@ -44,25 +41,25 @@ test("administrators review, apply and publish an import candidate", async ({ runId = String(run.id); const [target] = await sql` insert into public.catalogue_import_targets ( - run_id, kind, code, academic_year_id, item_id, item_year_id, baseline_snapshot_id, + run_id, kind, code, academic_year_id, code_id, record_id, baseline_version_id, status, change_kind, attempt_count, completed_at ) values ( - ${runId}::uuid, 'course', 'COMP1110', ${seed.academic_year_id}, ${seed.item_id}, - ${seed.item_year_id}, ${seed.published_snapshot_id}, 'ready', 'changed', 1, now() + ${runId}::uuid, 'course', 'COMP1110', ${seed.academic_year_id}, ${seed.code_id}, + ${seed.record_id}, ${seed.published_version_id}, 'ready', 'changed', 1, now() ) returning id `; // The candidate copies the published snapshot with a revised title. const [candidate] = await sql` - insert into public.catalogue_snapshots ( - item_year_id, kind, academic_year_id, origin, based_on_snapshot_id, content_hash, import_target_id + insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, based_on_version_id, content_hash, import_target_id ) values ( - ${seed.item_year_id}, 'course', ${seed.academic_year_id}, 'import', ${seed.published_snapshot_id}, + ${seed.record_id}, 'course', ${seed.academic_year_id}, 'import', ${seed.published_version_id}, repeat('b', 64), ${target.id}::uuid ) returning id `; await sql` - insert into public.course_snapshot_details ( - snapshot_id, title, unit_value_kind, units, minimum_units, maximum_units, eftsl, level, + insert into public.course_version_details ( + version_id, title, unit_value_kind, units, minimum_units, maximum_units, eftsl, level, subject_code, subject_name, school, college, academic_career, convener_text, delivery_summary, introduction, description, workload_text, workload_hours, inherent_requirements, prescribed_texts, offering_status, source_updated_at @@ -73,9 +70,9 @@ test("administrators review, apply and publish an import candidate", async ({ details.delivery_summary, details.introduction, 'A revised description for review.', details.workload_text, details.workload_hours, details.inherent_requirements, details.prescribed_texts, details.offering_status, details.source_updated_at - from public.course_snapshot_details as details where details.snapshot_id = ${seed.published_snapshot_id} + from public.course_version_details as details where details.version_id = ${seed.published_version_id} `; - await sql`update public.catalogue_import_targets set candidate_snapshot_id = ${candidate.id} where id = ${target.id}::uuid`; + await sql`update public.catalogue_import_targets set candidate_version_id = ${candidate.id} where id = ${target.id}::uuid`; await sql` insert into public.catalogue_import_changes (target_id, entry_kind, field_path, old_value, new_value, summary, position) values @@ -140,9 +137,9 @@ test("administrators review, apply and publish an import candidate", async ({ const [after] = await sql` select details.title, details.description - from public.catalogue_item_years as item_years - join public.course_snapshot_details as details on details.snapshot_id = item_years.published_snapshot_id - where item_years.id = ${seed.item_year_id} + from public.catalogue_records as item_years + join public.course_version_details as details on details.version_id = item_years.published_version_id + where item_years.id = ${seed.record_id} `; expect(after.title).toBe(`${seed.title} (revised)`); expect(after.description).not.toBe("A revised description for review."); @@ -154,14 +151,14 @@ test("administrators review, apply and publish an import candidate", async ({ } finally { // Restore the seed: republish the original snapshot and remove the run. const [seed] = await sql` - select item_years.id from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id where items.code = 'COMP1110' + select item_years.id from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id where items.code = 'COMP1110' `; const [original] = await sql` - select id from public.catalogue_snapshots where item_year_id = ${seed.id} and import_target_id is null + select id from public.catalogue_versions where record_id = ${seed.id} and import_target_id is null order by created_at limit 1 `; - await sql`update public.catalogue_item_years set draft_snapshot_id = null, published_snapshot_id = ${original.id} where id = ${seed.id}`; + await sql`update public.catalogue_records set published_version_id = ${original.id} where id = ${seed.id}`; if (runId) await sql`delete from public.catalogue_import_runs where id = ${runId}::uuid`; await sql.end(); diff --git a/apps/web/playwright/catalogue-workspace.spec.ts b/apps/web/playwright/catalogue-workspace.spec.ts index 2212f346..81e2480b 100644 --- a/apps/web/playwright/catalogue-workspace.spec.ts +++ b/apps/web/playwright/catalogue-workspace.spec.ts @@ -4,10 +4,10 @@ import { expect, login, test } from "./fixtures"; /** * The record workspace: preview the published course, edit its title and a - * learning outcome, save as a draft, publish, then restore the original from - * history and discard that draft. + * learning outcome, save a new version, publish, then restore the original + * content through another immutable version. */ -test("administrators edit, preview, publish and restore a course snapshot", async ({ +test("administrators edit, preview, publish and restore a course version", async ({ page, administrator, }) => { @@ -15,16 +15,13 @@ test("administrators edit, preview, publish and restore a course snapshot", asyn max: 1, }); const [seed] = await sql` - select item_years.id as item_year_id, item_years.published_snapshot_id, details.title - from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id - join public.course_snapshot_details as details on details.snapshot_id = item_years.published_snapshot_id - where items.code = 'COMP1100' and item_years.draft_snapshot_id is null + select item_years.id as record_id, item_years.published_version_id, details.title + from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id + join public.course_version_details as details on details.version_id = item_years.published_version_id + where items.code = 'COMP1100' `; - expect( - seed, - "the local seed publishes COMP1100 without a draft", - ).toBeTruthy(); + expect(seed, "the local seed publishes COMP1100").toBeTruthy(); try { await login(page, administrator); await page.goto("/admin/courses/COMP1100?year=2026&tab=preview"); @@ -55,25 +52,35 @@ test("administrators edit, preview, publish and restore a course snapshot", asyn page.getByRole("banner").getByRole("heading", { level: 1 }), ).toContainText(`${seed.title} (edited)`); const [afterEdit] = await sql` - select draft_snapshot_id, published_snapshot_id from public.catalogue_item_years where id = ${seed.item_year_id} + select item_years.published_version_id, + (select versions.id from public.catalogue_versions as versions + where versions.record_id = item_years.id + order by versions.created_at desc, versions.id desc limit 1) as current_version_id + from public.catalogue_records as item_years where item_years.id = ${seed.record_id} `; - expect(afterEdit.draft_snapshot_id).not.toBeNull(); - expect(Number(afterEdit.published_snapshot_id)).toBe( - Number(seed.published_snapshot_id), + expect(afterEdit.current_version_id).not.toBeNull(); + expect(Number(afterEdit.published_version_id)).toBe( + Number(seed.published_version_id), ); const [outcomeCount] = await sql` - select count(*)::int as count from public.course_learning_outcomes where snapshot_id = ${afterEdit.draft_snapshot_id} + select count(*)::int as count from public.course_learning_outcomes where version_id = ${afterEdit.current_version_id} `; expect(outcomeCount.count).toBeGreaterThanOrEqual(1); await page.getByRole("button", { name: "Publish draft" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Publish", exact: true }) + .click(); await expect( page.getByText("Published. Students now see this version."), ).toBeVisible(); await page.getByRole("tab", { name: "History" }).click(); const originalRow = page.getByRole("listitem").filter({ - has: page.getByText(`#${seed.published_snapshot_id}`, { exact: true }), + has: page.getByRole("heading", { + name: `Imported as #${seed.published_version_id}`, + }), }); await originalRow.getByRole("button", { name: "Restore as draft" }).click(); await expect(page.getByText(/is now the draft/)).toBeVisible(); @@ -83,23 +90,11 @@ test("administrators edit, preview, publish and restore a course snapshot", asyn await expect( page.getByRole("banner").getByRole("heading", { level: 1 }), ).not.toContainText("(edited)"); - - const draftRow = page - .getByRole("listitem") - .filter({ has: page.getByText("Draft", { exact: true }) }) - .first(); - await draftRow.getByRole("button", { name: "Discard" }).click(); - await page.getByRole("button", { name: "Discard draft" }).click(); - await expect(page.getByText("Draft discarded.")).toBeVisible(); - const [afterDiscard] = await sql` - select draft_snapshot_id from public.catalogue_item_years where id = ${seed.item_year_id} - `; - expect(afterDiscard.draft_snapshot_id).toBeNull(); } finally { await sql` - update public.catalogue_item_years - set draft_snapshot_id = null, published_snapshot_id = ${seed.published_snapshot_id} - where id = ${seed.item_year_id} + update public.catalogue_records + set published_version_id = ${seed.published_version_id} + where id = ${seed.record_id} `; await sql.end(); } diff --git a/apps/web/scripts/fixtures/local-preview.sql b/apps/web/scripts/fixtures/local-preview.sql index ec6812c9..a16ad7f1 100644 --- a/apps/web/scripts/fixtures/local-preview.sql +++ b/apps/web/scripts/fixtures/local-preview.sql @@ -234,7 +234,7 @@ where year = 2026; -- Published academic structures for exercising the complete student selection -- and requirements flow locally. These remain loopback-only fixture records. -insert into public.catalogue_items (kind, code) +insert into public.catalogue_codes (kind, code) values ('programme', 'LOCAL-PROGRAMME'), ('major', 'LOCAL-MAJ'), @@ -249,12 +249,12 @@ on conflict (kind, code) do nothing; -- MATH1005 deliberately remains an identity only. It is visible as a -- prerequisite placeholder without pretending its full 2026 course page has -- been imported. -insert into public.catalogue_item_years (item_id, kind, academic_year_id) +insert into public.catalogue_records (code_id, kind, academic_year_id) select items.id, items.kind, years.id -from public.catalogue_items as items +from public.catalogue_codes as items join public.academic_years as years on years.year = 2026 where items.code <> 'MATH1005' -on conflict (item_id, academic_year_id) do nothing; +on conflict (code_id, academic_year_id) do nothing; insert into public.catalogue_source_pages ( source_id, @@ -286,8 +286,8 @@ on conflict (source_id, academic_year_id, kind, external_key, content_sha256) do -- One snapshot per item year. Course snapshots carry their source page; the -- structure fixtures are manual. -insert into public.catalogue_snapshots ( - item_year_id, +insert into public.catalogue_versions ( + record_id, kind, academic_year_id, origin, @@ -303,15 +303,15 @@ select pages.id, md5(items.code || ':2026:local-preview') || md5('published:' || items.code), '90000000-0000-4000-8000-000000000001'::uuid -from public.catalogue_item_years as item_years -join public.catalogue_items as items on items.id = item_years.item_id +from public.catalogue_records as item_years +join public.catalogue_codes as items on items.id = item_years.code_id left join public.catalogue_source_pages as pages on pages.academic_year_id = item_years.academic_year_id and pages.kind = 'course' and pages.external_key = items.code; -insert into public.structure_snapshot_details ( - snapshot_id, +insert into public.structure_version_details ( + version_id, kind, name, description, @@ -335,13 +335,13 @@ select else 24 end, case when snapshots.kind = 'programme' then 3 else null end -from public.catalogue_snapshots as snapshots -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id +from public.catalogue_versions as snapshots +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id where snapshots.kind <> 'course'; insert into public.academic_structure_snapshot_relationships ( - snapshot_id, + version_id, position, relationship_kind, target_kind, @@ -359,9 +359,9 @@ select options.target_title, options.source_text, '#local-structure-options' -from public.catalogue_snapshots as snapshots -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id +from public.catalogue_versions as snapshots +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id cross join ( values (1, 'major'::text, 'LOCAL-MAJ'::text, 'Local Systems Major'::text, 'Choose the Local Systems Major.'::text), @@ -375,7 +375,7 @@ where items.code = 'LOCAL-PROGRAMME'; -- and a single condition. Programmes require 144 units; every other structure -- requires one listed local course. insert into public.requirement_rules ( - snapshot_id, academic_year_id, rule_kind, source_text, review_state + version_id, academic_year_id, rule_kind, source_text, review_state ) select snapshots.id, @@ -383,15 +383,15 @@ select 'structure', 'Complete all published requirements for ' || details.name || '.', 'verified' -from public.catalogue_snapshots as snapshots -join public.structure_snapshot_details as details on details.snapshot_id = snapshots.id; +from public.catalogue_versions as snapshots +join public.structure_version_details as details on details.version_id = snapshots.id; insert into public.requirement_groups ( - rule_id, snapshot_id, group_key, label, operator, source_text, source_locator, position + rule_id, version_id, group_key, label, operator, source_text, source_locator, position ) select rules.id, - rules.snapshot_id, + rules.version_id, 'root', details.name || ' requirements', 'all_of', @@ -399,16 +399,16 @@ select '#local-requirements', 0 from public.requirement_rules as rules -join public.structure_snapshot_details as details on details.snapshot_id = rules.snapshot_id +join public.structure_version_details as details on details.version_id = rules.version_id where rules.rule_kind = 'structure'; insert into public.requirement_conditions ( - rule_id, snapshot_id, group_id, condition_key, position, condition_kind, + rule_id, version_id, group_id, condition_key, position, condition_kind, minimum_units, source_text, source_locator, review_state ) select groups.rule_id, - groups.snapshot_id, + groups.version_id, groups.id, 'root:local-requirement', 0, @@ -422,24 +422,24 @@ select 'verified' from public.requirement_groups as groups join public.requirement_rules as rules on rules.id = groups.rule_id -join public.catalogue_snapshots as snapshots on snapshots.id = groups.snapshot_id +join public.catalogue_versions as snapshots on snapshots.id = groups.version_id where rules.rule_kind = 'structure'; insert into public.requirement_condition_options ( - condition_id, snapshot_id, position, kind, code, item_id + condition_id, version_id, position, kind, code, code_id ) select conditions.id, - conditions.snapshot_id, + conditions.version_id, 1, 'course', selected.code, selected.id from public.requirement_conditions as conditions -join public.catalogue_snapshots as snapshots on snapshots.id = conditions.snapshot_id -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id -join public.catalogue_items as selected +join public.catalogue_versions as snapshots on snapshots.id = conditions.version_id +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id +join public.catalogue_codes as selected on selected.kind = 'course' and selected.code = case items.code when 'LOCAL-MAJ' then 'COMP1110' @@ -451,8 +451,8 @@ where conditions.condition_kind = 'course_set_units'; -- Published courses ------------------------------------------------------------ -insert into public.course_snapshot_details ( - snapshot_id, +insert into public.course_version_details ( + version_id, title, unit_value_kind, units, @@ -502,16 +502,16 @@ select 'No prescribed text.', 'offered', '2026-08-01 00:00:00+10' -from public.catalogue_snapshots as snapshots -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id +from public.catalogue_versions as snapshots +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id where snapshots.kind = 'course'; -- Fixed-unit courses store their single value on the details row. Unit -- options are reserved for courses whose unit value is variable. insert into public.course_fees ( - snapshot_id, + version_id, position, fee_year, audience, @@ -533,27 +533,27 @@ select 'course', 'Indicative domestic fee', 'Indicative domestic fee: $1,110' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; -insert into public.course_areas_of_interest (snapshot_id, position, name) +insert into public.course_areas_of_interest (version_id, position, name) select snapshots.id, 1, 'Computer Science' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; insert into public.course_attributes ( - snapshot_id, + version_id, position, attribute_kind, value, source_text ) select snapshots.id, 1, 'stem', 'STEM', 'STEM course' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; insert into public.course_offerings ( - snapshot_id, + version_id, academic_year_id, source_page_id, delivery_mode, @@ -565,12 +565,12 @@ select snapshots.source_page_id, 'In person', 'Acton' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; insert into public.offering_sessions ( course_offering_id, - snapshot_id, + version_id, academic_year_id, source_page_id, academic_period_id, @@ -605,22 +605,22 @@ select 'Acton', 'https://coursemap.local.test/2026/classes/' || lower(items.code), 'Semester 1, in person at Acton' -from public.catalogue_snapshots as snapshots -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id -join public.course_offerings as offerings on offerings.snapshot_id = snapshots.id +from public.catalogue_versions as snapshots +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id +join public.course_offerings as offerings on offerings.version_id = snapshots.id join public.academic_periods as periods on periods.calendar_year = 2026 and periods.code = 'S1' where snapshots.kind = 'course'; -insert into public.course_learning_outcomes (snapshot_id, position, body) +insert into public.course_learning_outcomes (version_id, position, body) select snapshots.id, 1, 'Apply foundational programming concepts.' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; insert into public.course_assessment_items ( - snapshot_id, + version_id, position, title, weight, @@ -636,25 +636,25 @@ select false, 'Week 8', 'Programming assignment (40%)' -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots where snapshots.kind = 'course'; insert into public.course_assessment_outcomes ( - snapshot_id, + version_id, assessment_item_id, learning_outcome_id ) select snapshots.id, assessments.id, outcomes.id -from public.catalogue_snapshots as snapshots +from public.catalogue_versions as snapshots join public.course_assessment_items as assessments - on assessments.snapshot_id = snapshots.id + on assessments.version_id = snapshots.id join public.course_learning_outcomes as outcomes - on outcomes.snapshot_id = snapshots.id + on outcomes.version_id = snapshots.id and outcomes.position = 1 where snapshots.kind = 'course'; -insert into public.snapshot_field_evidence ( - snapshot_id, +insert into public.catalogue_version_provenance ( + version_id, academic_year_id, source_page_id, field_path, @@ -672,11 +672,11 @@ select 0.99, 'h1', details.title -from public.catalogue_snapshots as snapshots -join public.course_snapshot_details as details on details.snapshot_id = snapshots.id; +from public.catalogue_versions as snapshots +join public.course_version_details as details on details.version_id = snapshots.id; insert into public.requirement_rules ( - snapshot_id, academic_year_id, source_page_id, rule_kind, hardness, + version_id, academic_year_id, source_page_id, rule_kind, hardness, source_text, review_state, confidence ) select @@ -688,25 +688,25 @@ select 'You must have completed MATH1005.', 'verified', 0.99 -from public.catalogue_snapshots as snapshots -join public.catalogue_item_years as item_years on item_years.id = snapshots.item_year_id -join public.catalogue_items as items on items.id = item_years.item_id +from public.catalogue_versions as snapshots +join public.catalogue_records as item_years on item_years.id = snapshots.record_id +join public.catalogue_codes as items on items.id = item_years.code_id where items.code = 'COMP1110'; insert into public.requirement_groups ( - rule_id, snapshot_id, group_key, operator, position + rule_id, version_id, group_key, operator, position ) -select rules.id, rules.snapshot_id, 'prerequisite:group:root', 'all_of', 0 +select rules.id, rules.version_id, 'prerequisite:group:root', 'all_of', 0 from public.requirement_rules as rules where rules.rule_kind = 'prerequisite'; insert into public.requirement_conditions ( - rule_id, snapshot_id, group_id, condition_key, position, condition_kind, - item_id, item_kind, requirement_mode, hardness, source_text, confidence, review_state + rule_id, version_id, group_id, condition_key, position, condition_kind, + code_id, item_kind, requirement_mode, hardness, source_text, confidence, review_state ) select rules.id, - rules.snapshot_id, + rules.version_id, groups.id, 'prerequisite:condition:0', 0, @@ -720,24 +720,24 @@ select 'verified' from public.requirement_rules as rules join public.requirement_groups as groups on groups.rule_id = rules.id -join public.catalogue_items as prerequisite +join public.catalogue_codes as prerequisite on prerequisite.kind = 'course' and prerequisite.code = 'MATH1005' where rules.rule_kind = 'prerequisite'; insert into public.requirement_item_references ( - rule_id, snapshot_id, item_id, source_text, confidence, review_state + rule_id, version_id, code_id, source_text, confidence, review_state ) -select rules.id, rules.snapshot_id, prerequisite.id, 'MATH1005', 0.99, 'verified' +select rules.id, rules.version_id, prerequisite.id, 'MATH1005', 0.99, 'verified' from public.requirement_rules as rules -join public.catalogue_items as prerequisite +join public.catalogue_codes as prerequisite on prerequisite.kind = 'course' and prerequisite.code = 'MATH1005' where rules.rule_kind = 'prerequisite'; -- Setting the publication pointer is the only publication action. The pointer -- trigger seals each snapshot after every child row has been stored. -update public.catalogue_item_years as item_years -set published_snapshot_id = snapshots.id -from public.catalogue_snapshots as snapshots -where snapshots.item_year_id = item_years.id; +update public.catalogue_records as item_years +set published_version_id = snapshots.id +from public.catalogue_versions as snapshots +where snapshots.record_id = item_years.id; commit; diff --git a/apps/web/tests/academic-withdrawal.test.ts b/apps/web/tests/academic-withdrawal.test.ts index 262eb03f..f7081bfe 100644 --- a/apps/web/tests/academic-withdrawal.test.ts +++ b/apps/web/tests/academic-withdrawal.test.ts @@ -54,8 +54,7 @@ for (const grade of ["WD", "WL"]) { course_attempts: [ { id: "withdrawal", - course_id: 1, - course_snapshot_id: 10, + catalogue_version_id: 10, academic_period_id: 1, status: "withdrawn", grade, @@ -64,9 +63,10 @@ for (const grade of ["WD", "WL"]) { units_earned: 0, }, ], - catalogue_items: [{ id: 1, code: "COMP1100" }], + catalogue_codes: [{ id: 1, code: "COMP1100" }], + catalogue_records: [{ id: 5, code_id: 1, academic_year_id: 1 }], academic_periods: [{ id: 1, calendar_year: 2026, code: "S1" }], - catalogue_snapshots: [{ id: 10, academic_year_id: 1 }], + catalogue_versions: [{ id: 10, record_id: 5 }], }; const state = await loadCoursemapState({ id: "owner", email: null }); expect(state.attempts).toEqual([ diff --git a/apps/web/tests/catalogue-import-pipeline-database.test.mjs b/apps/web/tests/catalogue-import-pipeline-database.test.mjs index dde4c29b..680478eb 100644 --- a/apps/web/tests/catalogue-import-pipeline-database.test.mjs +++ b/apps/web/tests/catalogue-import-pipeline-database.test.mjs @@ -112,22 +112,22 @@ beforeAll(async () => { */ async function removeFixtureData() { await sql`delete from public.catalogue_import_runs where requested_by = ${ADMIN_ID}`; - await sql`update public.catalogue_directory_entries set item_id = null where code in (${CODE}, 'COMP2401')`; - await sql`alter table public.catalogue_snapshots disable trigger catalogue_snapshots_enforce_immutability`; + await sql`update public.catalogue_directory_entries set code_id = null where code in (${CODE}, 'COMP2401')`; + await sql`alter table public.catalogue_versions disable trigger catalogue_versions_enforce_immutability`; try { - await sql`delete from public.catalogue_items where kind = 'course' and code in (${CODE}, 'COMP2401')`; + await sql`delete from public.catalogue_codes where kind = 'course' and code in (${CODE}, 'COMP2401')`; await sql` - delete from public.catalogue_items + delete from public.catalogue_codes where kind = 'course' - and id not in (select item_id from public.catalogue_item_years) - and id not in (select item_id from public.requirement_conditions where item_id is not null) - and id not in (select item_id from public.requirement_condition_options where item_id is not null) - and id not in (select item_id from public.requirement_item_references) + and id not in (select code_id from public.catalogue_records) + and id not in (select code_id from public.requirement_conditions where code_id is not null) + and id not in (select code_id from public.requirement_condition_options where code_id is not null) + and id not in (select code_id from public.requirement_item_references) and id not in (select related_course_id from public.course_related_courses) and code not in ('MATH1005') `; } finally { - await sql`alter table public.catalogue_snapshots enable trigger catalogue_snapshots_enforce_immutability`; + await sql`alter table public.catalogue_versions enable trigger catalogue_versions_enforce_immutability`; } } @@ -154,7 +154,7 @@ async function startRun(codes) { return row.run; } -test("a first import becomes the draft and a repeat import is unchanged", async () => { +test("a first import creates an applied version and a repeat import is unchanged", async () => { const run = await startRun([CODE]); assert.equal(run.targets.length, 1); const targetId = run.targets[0].targetId; @@ -162,30 +162,30 @@ test("a first import becomes the draft and a repeat import is unchanged", async await processImportTarget({ runId: run.runId, targetId }); const [target] = await sql` - select status, change_kind, candidate_snapshot_id, error_message + select status, change_kind, candidate_version_id, applied_version_id, error_message from public.catalogue_import_targets where id = ${targetId}::uuid `; assert.equal(target.error_message, null); assert.equal(target.status, "ready"); assert.equal(target.change_kind, "new"); - assert.ok(target.candidate_snapshot_id); + assert.ok(target.candidate_version_id); + assert.equal( + Number(target.applied_version_id), + Number(target.candidate_version_id), + ); assert.equal(openRouterCalls, 1); const [itemYear] = await sql` - select item_years.draft_snapshot_id, item_years.published_snapshot_id - from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id + select item_years.published_version_id + from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id where items.code = ${CODE} `; - assert.equal( - Number(itemYear.draft_snapshot_id), - Number(target.candidate_snapshot_id), - ); - assert.equal(itemYear.published_snapshot_id, null); + assert.equal(itemYear.published_version_id, null); const [details] = await sql` - select title, units, subject_code from public.course_snapshot_details - where snapshot_id = ${target.candidate_snapshot_id} + select title, units, subject_code from public.course_version_details + where version_id = ${target.candidate_version_id} `; assert.equal(details.subject_code, "COMP"); assert.ok(details.title.length > 0); @@ -208,7 +208,7 @@ test("a first import becomes the draft and a repeat import is unchanged", async const [{ count: rules }] = await sql` select count(*)::int as count from public.requirement_rules - where snapshot_id = ${target.candidate_snapshot_id} + where version_id = ${target.candidate_version_id} `; assert.ok(rules >= 1, "the fixture page carries requisite rules"); @@ -220,19 +220,19 @@ test("a first import becomes the draft and a repeat import is unchanged", async assert.equal(Number(runRow.cost_usd), 0.001); // A second run over identical content reuses the validated response and - // records no new snapshot. + // records no new version. const secondRun = await startRun([CODE]); const { completed } = await processImportRunInline({ runId: secondRun.runId, }); assert.equal(completed, 1); const [second] = await sql` - select status, change_kind, candidate_snapshot_id from public.catalogue_import_targets + select status, change_kind, candidate_version_id from public.catalogue_import_targets where run_id = ${secondRun.runId}::uuid `; assert.equal(second.status, "unchanged"); assert.equal(second.change_kind, "unchanged"); - assert.equal(second.candidate_snapshot_id, null); + assert.equal(second.candidate_version_id, null); assert.equal( openRouterCalls, 1, @@ -243,19 +243,19 @@ test("a first import becomes the draft and a repeat import is unchanged", async test("a changed import records open changes, applies accepted ones and publishes", async () => { // Alter the published fixture so the next import differs in the title only. const [item] = await sql` - select item_years.id as item_year_id, item_years.draft_snapshot_id - from public.catalogue_item_years as item_years - join public.catalogue_items as items on items.id = item_years.item_id + select item_years.id as record_id, item_years.published_version_id + from public.catalogue_records as item_years + join public.catalogue_codes as items on items.id = item_years.code_id where items.code = ${CODE} `; await sql.begin(async (tx) => { await tx`select set_config('request.jwt.claim.sub', ${ADMIN_ID}, true)`; - await tx`select public.publish_catalogue_snapshot(${item.item_year_id})`; + await tx`select public.publish_catalogue_version(${item.record_id})`; }); const [{ count: acceptedOnFirst }] = await sql` select count(*)::int as count from public.catalogue_import_changes as changes join public.catalogue_import_targets as targets on targets.id = changes.target_id - where targets.item_year_id = ${item.item_year_id} and changes.entry_kind = 'change' and changes.status = 'accepted' + where targets.record_id = ${item.record_id} and changes.entry_kind = 'change' and changes.status = 'accepted' `; assert.ok( acceptedOnFirst > 0, @@ -290,7 +290,7 @@ test("a changed import records open changes, applies accepted ones and publishes const run = await startRun([CODE]); await processImportRunInline({ runId: run.runId }); const [target] = await sql` - select id, status, change_kind, candidate_snapshot_id from public.catalogue_import_targets + select id, status, change_kind, candidate_version_id from public.catalogue_import_targets where run_id = ${run.runId}::uuid `; assert.equal(target.status, "ready"); @@ -307,11 +307,11 @@ test("a changed import records open changes, applies accepted ones and publishes // Publishing is blocked while changes are open. const [{ blockers }] = await sql` - select public.catalogue_publish_blockers(${item.item_year_id}) as blockers + select public.catalogue_publish_blockers(${item.record_id}) as blockers `; assert.ok( blockers.some((reason) => - /open changes|no draft|already published/.test(reason), + /open changes|no version|already published/.test(reason), ), blockers.join(" "), ); @@ -335,13 +335,13 @@ test("a changed import records open changes, applies accepted ones and publishes assert.equal( applied.reusedCandidate, false, - "a partial acceptance builds a merged snapshot", + "a partial acceptance builds a merged version", ); const [merged] = await sql` select details.title, details.introduction - from public.course_snapshot_details as details - where details.snapshot_id = ${applied.draftSnapshotId} + from public.course_version_details as details + where details.version_id = ${applied.versionId} `; assert.equal(merged.title, revisedDeterministic.title); assert.equal( @@ -351,13 +351,9 @@ test("a changed import records open changes, applies accepted ones and publishes ); const [pointer] = await sql` - select draft_snapshot_id, published_snapshot_id from public.catalogue_item_years where id = ${item.item_year_id} + select published_version_id from public.catalogue_records where id = ${item.record_id} `; - assert.equal(Number(pointer.draft_snapshot_id), applied.draftSnapshotId); - assert.notEqual( - Number(pointer.published_snapshot_id), - applied.draftSnapshotId, - ); + assert.notEqual(Number(pointer.published_version_id), applied.versionId); await assert.rejects( applyImportReview({ targetId: target.id, userId: ADMIN_ID, sql }), @@ -366,15 +362,12 @@ test("a changed import records open changes, applies accepted ones and publishes await sql.begin(async (tx) => { await tx`select set_config('request.jwt.claim.sub', ${ADMIN_ID}, true)`; - await tx`select public.publish_catalogue_snapshot(${item.item_year_id})`; + await tx`select public.publish_catalogue_version(${item.record_id})`; }); const [published] = await sql` - select published_snapshot_id from public.catalogue_item_years where id = ${item.item_year_id} + select published_version_id from public.catalogue_records where id = ${item.record_id} `; - assert.equal( - Number(published.published_snapshot_id), - applied.draftSnapshotId, - ); + assert.equal(Number(published.published_version_id), applied.versionId); } finally { pageRef.current = previousPage; modelAnswerRef.current = previousModel; diff --git a/apps/web/tests/catalogue-review-state.test.ts b/apps/web/tests/catalogue-review-state.test.ts index 365638b8..d5a9b6e2 100644 --- a/apps/web/tests/catalogue-review-state.test.ts +++ b/apps/web/tests/catalogue-review-state.test.ts @@ -45,8 +45,8 @@ function target(overrides: Partial = {}): ReviewTarget { createdAt: "2026-03-01T00:00:00Z", completedAt: "2026-03-01T00:01:00Z", appliedAt: null, - baselineSnapshotId: 10, - candidateSnapshotId: 11, + baselineVersionId: 10, + candidateVersionId: 11, entries: [], ...overrides, }; @@ -57,15 +57,15 @@ function record(overrides: Partial = {}): CatalogueRecord { kind: "course", code: "COMP3600", academicYear: 2026, - itemId: 1, - itemYearId: 1, - itemYearPublicId: "iy_1", + codeId: 1, + recordId: 1, + recordPublicId: "iy_1", title: "Algorithms", - draftSnapshotId: null, - publishedSnapshotId: null, + currentVersionId: null, + publishedVersionId: null, archivedAt: null, publishBlockers: [], - snapshots: [], + versions: [], publications: [], reviews: [], ...overrides, @@ -155,7 +155,7 @@ test("the record's next step walks from decisions to published", () => { const outstanding = recordNextStep( record({ - draftSnapshotId: 5, + currentVersionId: 5, reviews, publishBlockers: ["The import review still has open changes."], }), @@ -166,7 +166,7 @@ test("the record's next step walks from decisions to published", () => { const decided = recordNextStep( record({ - draftSnapshotId: 5, + currentVersionId: 5, reviews: [target({ entries: [entry({ status: "accepted" })] })], }), ); @@ -175,7 +175,7 @@ test("the record's next step walks from decisions to published", () => { const ready = recordNextStep( record({ - draftSnapshotId: 5, + currentVersionId: 5, reviews: [ target({ appliedAt: "2026-03-02T00:00:00Z", @@ -187,7 +187,7 @@ test("the record's next step walks from decisions to published", () => { expect(ready.headline).toBe("Ready to publish"); expect(ready.next).toBe("publish"); - const live = recordNextStep(record({ publishedSnapshotId: 5 })); + const live = recordNextStep(record({ publishedVersionId: 5 })); expect(live.headline).toBe("Published"); expect(live.next).toBe("none"); }); @@ -195,7 +195,7 @@ test("the record's next step walks from decisions to published", () => { test("a blocking flag holds publication and names itself", () => { const step = recordNextStep( record({ - draftSnapshotId: 5, + currentVersionId: 5, publishBlockers: ["A blocking flag on the import review is still open."], reviews: [ target({ @@ -220,15 +220,15 @@ test("decisions from a superseded import do not hold a record back", () => { // behind the draft, so the interface counts the same two and no others. const step = recordNextStep( record({ - draftSnapshotId: 5, - snapshots: [ + currentVersionId: 5, + versions: [ { id: 5, publicId: "sn_5", origin: "import", createdAt: "2026-03-02T00:00:00Z", sealedAt: null, - basedOnSnapshotId: null, + basedOnVersionId: null, importTargetId: "target-1", contentHash: "abc", }, diff --git a/apps/web/tests/local-preview-seed.test.mjs b/apps/web/tests/local-preview-seed.test.mjs index ce233a58..2ff68524 100644 --- a/apps/web/tests/local-preview-seed.test.mjs +++ b/apps/web/tests/local-preview-seed.test.mjs @@ -39,7 +39,7 @@ test("the local preview publishes every selectable academic structure kind", asy assert.match(previewSeed, /LOCALA-MIN/u); assert.match(previewSeed, /LOCALB-MIN/u); assert.match(previewSeed, /LOCAL-SPEC/u); - assert.match(previewSeed, /set published_snapshot_id = snapshots\.id/u); + assert.match(previewSeed, /set published_version_id = snapshots\.id/u); }); test("passes the local server key to durable import workers", async () => { diff --git a/apps/web/tests/plan-catalogue-requirements.test.mjs b/apps/web/tests/plan-catalogue-requirements.test.mjs index e4a857b2..7f3caf54 100644 --- a/apps/web/tests/plan-catalogue-requirements.test.mjs +++ b/apps/web/tests/plan-catalogue-requirements.test.mjs @@ -18,7 +18,7 @@ function group(overrides) { parent_group_id: null, position: 1, rule_id: 5, - snapshot_id: 70, + version_id: 70, source_locator: "#program-requirements", source_text: "Complete all of the following requirements.", ...overrides, @@ -34,7 +34,7 @@ function condition(overrides) { group_id: 1, hardness: "hard", id: 10, - item_id: null, + code_id: null, maximum_level: null, maximum_units: null, minimum_count: null, @@ -48,7 +48,7 @@ function condition(overrides) { requirement_mode: null, review_state: "verified", rule_id: 5, - snapshot_id: 70, + version_id: 70, source_locator: "#program-requirements", source_text: "144 units", structure_kind: null, @@ -99,10 +99,10 @@ test("rebuilds nested requirement groups with ordered alternatives and options", code: "COMP1100", condition_id: 11, id: 20, - item_id: null, + code_id: null, kind: "course", position: 1, - snapshot_id: 70, + version_id: 70, source_text: null, title: null, }, @@ -110,10 +110,10 @@ test("rebuilds nested requirement groups with ordered alternatives and options", code: "COMP1130", condition_id: 12, id: 21, - item_id: null, + code_id: null, kind: "course", position: 1, - snapshot_id: 70, + version_id: 70, source_text: null, title: null, }, @@ -161,7 +161,7 @@ test("selects the latest year through published programme pointers and loads rel ); assert.match(source, /\.eq\("kind", "programme"\)/u); - assert.match(source, /\.not\("published_snapshot_id", "is", null\)/u); + assert.match(source, /\.not\("published_version_id", "is", null\)/u); assert.match(source, /\.order\("year", \{ ascending: false \}\)/u); assert.match(source, /from\("plan_structures"\)/u); assert.match(source, /selectedStructureYears\.has\(structureYear\.id\)/u); diff --git a/apps/web/tests/plan-catalogue.test.mjs b/apps/web/tests/plan-catalogue.test.mjs index 1c60b315..3bf8bd57 100644 --- a/apps/web/tests/plan-catalogue.test.mjs +++ b/apps/web/tests/plan-catalogue.test.mjs @@ -1,20 +1,27 @@ import assert from "node:assert/strict"; import { test } from "vitest"; -import { collectPlanCatalogueCourseIds } from "../lib/coursemap/plan-course-ids.ts"; +import { collectPlanCatalogueRecordIds } from "../lib/coursemap/plan-course-ids.ts"; -test("includes courses that only appear in recorded attempts", () => { +test("includes records that only appear in recorded attempts", () => { assert.deepEqual( - collectPlanCatalogueCourseIds([{ course_id: 101 }], [{ course_id: 202 }]), + collectPlanCatalogueRecordIds( + [{ catalogue_record_id: 101 }], + [{ catalogue_record_id: 202 }], + ), [101, 202], ); }); -test("deduplicates courses shared by the plan and recorded attempts", () => { +test("deduplicates records shared by the plan and recorded attempts", () => { assert.deepEqual( - collectPlanCatalogueCourseIds( - [{ course_id: 101 }, { course_id: 202 }], - [{ course_id: 202 }, { course_id: 303 }, { course_id: 303 }], + collectPlanCatalogueRecordIds( + [{ catalogue_record_id: 101 }, { catalogue_record_id: 202 }], + [ + { catalogue_record_id: 202 }, + { catalogue_record_id: 303 }, + { catalogue_record_id: 303 }, + ], ), [101, 202, 303], ); diff --git a/apps/web/tests/programme-structure-options.test.mjs b/apps/web/tests/programme-structure-options.test.mjs index 90e07e65..79974bc2 100644 --- a/apps/web/tests/programme-structure-options.test.mjs +++ b/apps/web/tests/programme-structure-options.test.mjs @@ -15,7 +15,7 @@ test("keeps only explicit programme structure relationship semantics", () => { relationship("incompatible", "ANTH-MAJ"), relationship("other", "ECON-MAJ"), relationship("required", "DATA-MIN", "minor"), - { ...relationship("required", "CHEM-MAJ"), snapshot_id: 202 }, + { ...relationship("required", "CHEM-MAJ"), version_id: 202 }, ], requirementConditions: [], requirementOptions: [], @@ -38,7 +38,7 @@ test("includes structure options from programme structure-set requirements", () condition(2, "course_set_units", "major"), condition(3, "structure_set", "minor"), condition(5, "structure_set", "specialisation"), - { ...condition(4, "structure_set", "major"), snapshot_id: 202 }, + { ...condition(4, "structure_set", "major"), version_id: 202 }, ], requirementOptions: [ option(1, "MATH-MAJ"), @@ -46,8 +46,8 @@ test("includes structure options from programme structure-set requirements", () option(2, "STAT-MAJ"), option(3, "DATA-MIN", "minor"), option(5, "AI-SPEC", "specialisation"), - { ...option(1, "PHYS-MAJ"), snapshot_id: 202 }, - { ...option(4, "CHEM-MAJ"), snapshot_id: 202 }, + { ...option(1, "PHYS-MAJ"), version_id: 202 }, + { ...option(4, "CHEM-MAJ"), version_id: 202 }, { ...option(1, "ECON-MAJ"), kind: "course" }, ], }); @@ -67,10 +67,7 @@ test("onboarding loads explicit relationship and structure-set semantics without ); assert.match(source, /collectSelectableStructureCodes/u); - assert.match( - source, - /relationship_kind,snapshot_id,target_code,target_kind/u, - ); + assert.match(source, /relationship_kind,version_id,target_code,target_kind/u); assert.match(source, /from\("requirement_conditions"\)/u); assert.match(source, /from\("requirement_condition_options"\)/u); assert.doesNotMatch(source, /snapshot\.units === null \? 0/u); @@ -79,7 +76,7 @@ test("onboarding loads explicit relationship and structure-set semantics without function relationship(relationshipKind, targetCode, targetKind = "major") { return { relationship_kind: relationshipKind, - snapshot_id: 101, + version_id: 101, target_code: targetCode, target_kind: targetKind, }; @@ -89,7 +86,7 @@ function condition(id, conditionKind, structureKind) { return { condition_kind: conditionKind, id, - snapshot_id: 101, + version_id: 101, structure_kind: structureKind, }; } @@ -99,6 +96,6 @@ function option(conditionId, code, kind = "major") { code, condition_id: conditionId, kind, - snapshot_id: 101, + version_id: 101, }; } diff --git a/apps/web/tests/structure-role-selection.test.tsx b/apps/web/tests/structure-role-selection.test.tsx index 3d0f4f44..b1eaea6d 100644 --- a/apps/web/tests/structure-role-selection.test.tsx +++ b/apps/web/tests/structure-role-selection.test.tsx @@ -29,11 +29,11 @@ const codeByYear = new Map([ test("a plan keeps every selected minor and specialisation", () => { const codes = planStructureCodes( [ - { role: "programme", structure_year_id: 1 }, - { role: "major", structure_year_id: 2 }, - { role: "minor", structure_year_id: 3 }, - { role: "minor", structure_year_id: 4 }, - { role: "specialisation", structure_year_id: 5 }, + { role: "programme", catalogue_record_id: 1 }, + { role: "major", catalogue_record_id: 2 }, + { role: "minor", catalogue_record_id: 3 }, + { role: "minor", catalogue_record_id: 4 }, + { role: "specialisation", catalogue_record_id: 5 }, ], codeByYear, { degreeCode: "FALLBACK" }, @@ -50,8 +50,8 @@ test("a plan keeps every selected minor and specialisation", () => { test("a structure with no resolvable code is dropped, not recorded blank", () => { const codes = planStructureCodes( [ - { role: "minor", structure_year_id: 3 }, - { role: "minor", structure_year_id: 6 }, + { role: "minor", catalogue_record_id: 3 }, + { role: "minor", catalogue_record_id: 6 }, ], codeByYear, { degreeCode: "FALLBACK" }, diff --git a/apps/web/types/database.ts b/apps/web/types/database.ts index 8c0df089..68582a60 100644 --- a/apps/web/types/database.ts +++ b/apps/web/types/database.ts @@ -61,10 +61,10 @@ export type Database = { fee_year: number | null id: number position: number - snapshot_id: number source_label: string | null source_locator: string source_text: string + version_id: number } Insert: { amount?: number | null @@ -75,10 +75,10 @@ export type Database = { fee_year?: number | null id?: never position: number - snapshot_id: number source_label?: string | null source_locator: string source_text: string + version_id: number } Update: { amount?: number | null @@ -89,25 +89,25 @@ export type Database = { fee_year?: number | null id?: never position?: number - snapshot_id?: number source_label?: string | null source_locator?: string source_text?: string + version_id?: number } Relationships: [ { foreignKeyName: "academic_structure_fees_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "academic_structure_fees_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -116,40 +116,40 @@ export type Database = { id: number outcome_text: string position: number - snapshot_id: number source_locator: string source_text: string + version_id: number } Insert: { id?: never outcome_text: string position: number - snapshot_id: number source_locator: string source_text: string + version_id: number } Update: { id?: never outcome_text?: string position?: number - snapshot_id?: number source_locator?: string source_text?: string + version_id?: number } Relationships: [ { foreignKeyName: "academic_structure_learning_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "academic_structure_learning_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -158,49 +158,49 @@ export type Database = { id: number position: number relationship_kind: string - snapshot_id: number source_locator: string source_text: string target_code: string target_kind: string target_title: string | null + version_id: number } Insert: { id?: never position: number relationship_kind: string - snapshot_id: number source_locator: string source_text: string target_code: string target_kind: string target_title?: string | null + version_id: number } Update: { id?: never position?: number relationship_kind?: string - snapshot_id?: number source_locator?: string source_text?: string target_code?: string target_kind?: string target_title?: string | null + version_id?: number } Relationships: [ { foreignKeyName: "academic_structure_snapshot_relationships_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "academic_structure_snapshot_relationships_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -211,9 +211,9 @@ export type Database = { markdown: string position: number section_key: string - snapshot_id: number source_locator: string source_text: string + version_id: number } Insert: { heading: string @@ -221,9 +221,9 @@ export type Database = { markdown: string position: number section_key: string - snapshot_id: number source_locator: string source_text: string + version_id: number } Update: { heading?: string @@ -231,24 +231,24 @@ export type Database = { markdown?: string position?: number section_key?: string - snapshot_id?: number source_locator?: string source_text?: string + version_id?: number } Relationships: [ { foreignKeyName: "academic_structure_snapshot_sections_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "academic_structure_snapshot_sections_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -800,14 +800,41 @@ export type Database = { }, ] } + catalogue_codes: { + Row: { + code: string + created_at: string + id: number + kind: string + public_id: string + updated_at: string + } + Insert: { + code: string + created_at?: string + id?: never + kind: string + public_id?: string + updated_at?: string + } + Update: { + code?: string + created_at?: string + id?: never + kind?: string + public_id?: string + updated_at?: string + } + Relationships: [] + } catalogue_directory_entries: { Row: { academic_year_id: number code: string + code_id: number | null first_seen_at: string id: number is_current: boolean - item_id: number | null kind: string last_seen_at: string source_page_id: number | null @@ -817,10 +844,10 @@ export type Database = { Insert: { academic_year_id: number code: string + code_id?: number | null first_seen_at?: string id?: never is_current?: boolean - item_id?: number | null kind: string last_seen_at?: string source_page_id?: number | null @@ -830,10 +857,10 @@ export type Database = { Update: { academic_year_id?: number code?: string + code_id?: number | null first_seen_at?: string id?: never is_current?: boolean - item_id?: number | null kind?: string last_seen_at?: string source_page_id?: number | null @@ -850,9 +877,9 @@ export type Database = { }, { foreignKeyName: "catalogue_directory_entries_item_fkey" - columns: ["item_id", "kind"] + columns: ["code_id", "kind"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id", "kind"] }, { @@ -1288,12 +1315,13 @@ export type Database = { Row: { academic_year_id: number applied_at: string | null - applied_snapshot_id: number | null + applied_version_id: number | null attempt_count: number - baseline_snapshot_id: number | null - candidate_snapshot_id: number | null + baseline_version_id: number | null + candidate_version_id: number | null change_kind: string | null code: string + code_id: number completed_at: string | null created_at: string directory_entry_id: number | null @@ -1301,12 +1329,11 @@ export type Database = { error_code: string | null error_message: string | null id: string - item_id: number - item_year_id: number kind: string lease_expires_at: string | null lock_version: number queue_message_id: string | null + record_id: number run_id: string source_page_id: number | null status: string @@ -1316,12 +1343,13 @@ export type Database = { Insert: { academic_year_id: number applied_at?: string | null - applied_snapshot_id?: number | null + applied_version_id?: number | null attempt_count?: number - baseline_snapshot_id?: number | null - candidate_snapshot_id?: number | null + baseline_version_id?: number | null + candidate_version_id?: number | null change_kind?: string | null code: string + code_id: number completed_at?: string | null created_at?: string directory_entry_id?: number | null @@ -1329,12 +1357,11 @@ export type Database = { error_code?: string | null error_message?: string | null id?: string - item_id: number - item_year_id: number kind: string lease_expires_at?: string | null lock_version?: number queue_message_id?: string | null + record_id: number run_id: string source_page_id?: number | null status?: string @@ -1344,12 +1371,13 @@ export type Database = { Update: { academic_year_id?: number applied_at?: string | null - applied_snapshot_id?: number | null + applied_version_id?: number | null attempt_count?: number - baseline_snapshot_id?: number | null - candidate_snapshot_id?: number | null + baseline_version_id?: number | null + candidate_version_id?: number | null change_kind?: string | null code?: string + code_id?: number completed_at?: string | null created_at?: string directory_entry_id?: number | null @@ -1357,12 +1385,11 @@ export type Database = { error_code?: string | null error_message?: string | null id?: string - item_id?: number - item_year_id?: number kind?: string lease_expires_at?: string | null lock_version?: number queue_message_id?: string | null + record_id?: number run_id?: string source_page_id?: number | null status?: string @@ -1372,24 +1399,24 @@ export type Database = { Relationships: [ { foreignKeyName: "catalogue_import_targets_applied_fkey" - columns: ["applied_snapshot_id", "item_year_id"] + columns: ["applied_version_id", "record_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] }, { foreignKeyName: "catalogue_import_targets_baseline_fkey" - columns: ["baseline_snapshot_id", "item_year_id"] + columns: ["baseline_version_id", "record_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] }, { foreignKeyName: "catalogue_import_targets_candidate_fkey" - columns: ["candidate_snapshot_id", "item_year_id"] + columns: ["candidate_version_id", "record_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] }, { foreignKeyName: "catalogue_import_targets_directory_entry_fkey" @@ -1400,24 +1427,24 @@ export type Database = { }, { foreignKeyName: "catalogue_import_targets_item_fkey" - columns: ["item_id", "kind"] + columns: ["code_id", "kind"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id", "kind"] }, { foreignKeyName: "catalogue_import_targets_item_year_fkey" - columns: ["item_year_id", "academic_year_id"] + columns: ["record_id", "academic_year_id"] isOneToOne: false - referencedRelation: "catalogue_item_years" + referencedRelation: "catalogue_records" referencedColumns: ["id", "academic_year_id"] }, { foreignKeyName: "catalogue_import_targets_item_year_fkey" - columns: ["item_year_id", "academic_year_id"] + columns: ["record_id", "academic_year_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_year_id", "academic_year_id"] + referencedColumns: ["record_id", "academic_year_id"] }, { foreignKeyName: "catalogue_import_targets_run_fkey" @@ -1435,242 +1462,113 @@ export type Database = { }, ] } - catalogue_item_years: { - Row: { - academic_year_id: number - archived_at: string | null - created_at: string - draft_snapshot_id: number | null - id: number - item_id: number - kind: string - public_id: string - published_snapshot_id: number | null - updated_at: string - } - Insert: { - academic_year_id: number - archived_at?: string | null - created_at?: string - draft_snapshot_id?: number | null - id?: never - item_id: number - kind: string - public_id?: string - published_snapshot_id?: number | null - updated_at?: string - } - Update: { - academic_year_id?: number - archived_at?: string | null - created_at?: string - draft_snapshot_id?: number | null - id?: never - item_id?: number - kind?: string - public_id?: string - published_snapshot_id?: number | null - updated_at?: string - } - Relationships: [ - { - foreignKeyName: "catalogue_item_years_academic_year_id_fkey" - columns: ["academic_year_id"] - isOneToOne: false - referencedRelation: "academic_years" - referencedColumns: ["id"] - }, - { - foreignKeyName: "catalogue_item_years_draft_snapshot_fkey" - columns: ["draft_snapshot_id", "id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] - }, - { - foreignKeyName: "catalogue_item_years_item_kind_fkey" - columns: ["item_id", "kind"] - isOneToOne: false - referencedRelation: "catalogue_items" - referencedColumns: ["id", "kind"] - }, - { - foreignKeyName: "catalogue_item_years_published_snapshot_fkey" - columns: ["published_snapshot_id", "id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] - }, - ] - } - catalogue_items: { - Row: { - code: string - created_at: string - id: number - kind: string - public_id: string - updated_at: string - } - Insert: { - code: string - created_at?: string - id?: never - kind: string - public_id?: string - updated_at?: string - } - Update: { - code?: string - created_at?: string - id?: never - kind?: string - public_id?: string - updated_at?: string - } - Relationships: [] - } catalogue_publications: { Row: { id: number - item_year_id: number published_at: string published_by: string | null - snapshot_id: number | null + record_id: number + unpublished_at: string | null + unpublished_by: string | null + version_id: number } Insert: { id?: never - item_year_id: number published_at?: string published_by?: string | null - snapshot_id?: number | null + record_id: number + unpublished_at?: string | null + unpublished_by?: string | null + version_id: number } Update: { id?: never - item_year_id?: number published_at?: string published_by?: string | null - snapshot_id?: number | null + record_id?: number + unpublished_at?: string | null + unpublished_by?: string | null + version_id?: number } Relationships: [ { - foreignKeyName: "catalogue_publications_item_year_id_fkey" - columns: ["item_year_id"] + foreignKeyName: "catalogue_publications_record_id_fkey" + columns: ["record_id"] isOneToOne: false - referencedRelation: "catalogue_item_years" + referencedRelation: "catalogue_records" referencedColumns: ["id"] }, { - foreignKeyName: "catalogue_publications_item_year_id_fkey" - columns: ["item_year_id"] + foreignKeyName: "catalogue_publications_record_id_fkey" + columns: ["record_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_year_id"] + referencedColumns: ["record_id"] }, { - foreignKeyName: "catalogue_publications_snapshot_id_fkey" - columns: ["snapshot_id"] + foreignKeyName: "catalogue_publications_version_record_fkey" + columns: ["version_id", "record_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id"] - }, - { - foreignKeyName: "catalogue_publications_snapshot_id_fkey" - columns: ["snapshot_id"] - isOneToOne: false - referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] }, ] } - catalogue_snapshots: { + catalogue_records: { Row: { academic_year_id: number - based_on_snapshot_id: number | null - content_hash: string + archived_at: string | null + code_id: number created_at: string - created_by: string | null id: number - import_target_id: string | null - item_year_id: number kind: string - origin: string public_id: string - sealed_at: string | null - source_page_id: number | null + published_version_id: number | null + updated_at: string } Insert: { academic_year_id: number - based_on_snapshot_id?: number | null - content_hash: string + archived_at?: string | null + code_id: number created_at?: string - created_by?: string | null id?: never - import_target_id?: string | null - item_year_id: number kind: string - origin: string public_id?: string - sealed_at?: string | null - source_page_id?: number | null + published_version_id?: number | null + updated_at?: string } Update: { academic_year_id?: number - based_on_snapshot_id?: number | null - content_hash?: string + archived_at?: string | null + code_id?: number created_at?: string - created_by?: string | null id?: never - import_target_id?: string | null - item_year_id?: number kind?: string - origin?: string public_id?: string - sealed_at?: string | null - source_page_id?: number | null + published_version_id?: number | null + updated_at?: string } Relationships: [ { - foreignKeyName: "catalogue_snapshots_based_on_fkey" - columns: ["based_on_snapshot_id", "item_year_id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "item_year_id"] - }, - { - foreignKeyName: "catalogue_snapshots_import_target_fkey" - columns: ["import_target_id"] + foreignKeyName: "catalogue_records_academic_year_id_fkey" + columns: ["academic_year_id"] isOneToOne: false - referencedRelation: "catalogue_import_targets" + referencedRelation: "academic_years" referencedColumns: ["id"] }, { - foreignKeyName: "catalogue_snapshots_item_year_fkey" - columns: ["item_year_id", "academic_year_id"] - isOneToOne: false - referencedRelation: "catalogue_item_years" - referencedColumns: ["id", "academic_year_id"] - }, - { - foreignKeyName: "catalogue_snapshots_item_year_fkey" - columns: ["item_year_id", "academic_year_id"] + foreignKeyName: "catalogue_records_code_kind_fkey" + columns: ["code_id", "kind"] isOneToOne: false - referencedRelation: "published_course_summaries" - referencedColumns: ["item_year_id", "academic_year_id"] - }, - { - foreignKeyName: "catalogue_snapshots_item_year_kind_fkey" - columns: ["item_year_id", "kind"] - isOneToOne: false - referencedRelation: "catalogue_item_years" + referencedRelation: "catalogue_codes" referencedColumns: ["id", "kind"] }, { - foreignKeyName: "catalogue_snapshots_source_page_fkey" - columns: ["source_page_id", "academic_year_id"] + foreignKeyName: "catalogue_records_published_version_fkey" + columns: ["published_version_id", "id"] isOneToOne: false - referencedRelation: "catalogue_source_pages" - referencedColumns: ["id", "academic_year_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] }, ] } @@ -1776,42 +1674,187 @@ export type Database = { } Relationships: [] } + catalogue_version_provenance: { + Row: { + academic_year_id: number + confidence: number | null + created_at: string + field_path: string + id: number + method: string + source_excerpt: string | null + source_locator: string | null + source_page_id: number | null + version_id: number + } + Insert: { + academic_year_id: number + confidence?: number | null + created_at?: string + field_path: string + id?: never + method: string + source_excerpt?: string | null + source_locator?: string | null + source_page_id?: number | null + version_id: number + } + Update: { + academic_year_id?: number + confidence?: number | null + created_at?: string + field_path?: string + id?: never + method?: string + source_excerpt?: string | null + source_locator?: string | null + source_page_id?: number | null + version_id?: number + } + Relationships: [ + { + foreignKeyName: "catalogue_version_provenance_snapshot_fkey" + columns: ["version_id", "academic_year_id"] + isOneToOne: false + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "academic_year_id"] + }, + { + foreignKeyName: "catalogue_version_provenance_source_page_fkey" + columns: ["source_page_id", "academic_year_id"] + isOneToOne: false + referencedRelation: "catalogue_source_pages" + referencedColumns: ["id", "academic_year_id"] + }, + ] + } + catalogue_versions: { + Row: { + academic_year_id: number + based_on_version_id: number | null + content_hash: string + created_at: string + created_by: string | null + id: number + import_target_id: string | null + kind: string + origin: string + public_id: string + record_id: number + sealed_at: string | null + source_page_id: number | null + } + Insert: { + academic_year_id: number + based_on_version_id?: number | null + content_hash: string + created_at?: string + created_by?: string | null + id?: never + import_target_id?: string | null + kind: string + origin: string + public_id?: string + record_id: number + sealed_at?: string | null + source_page_id?: number | null + } + Update: { + academic_year_id?: number + based_on_version_id?: number | null + content_hash?: string + created_at?: string + created_by?: string | null + id?: never + import_target_id?: string | null + kind?: string + origin?: string + public_id?: string + record_id?: number + sealed_at?: string | null + source_page_id?: number | null + } + Relationships: [ + { + foreignKeyName: "catalogue_versions_based_on_fkey" + columns: ["based_on_version_id", "record_id"] + isOneToOne: false + referencedRelation: "catalogue_versions" + referencedColumns: ["id", "record_id"] + }, + { + foreignKeyName: "catalogue_versions_import_target_fkey" + columns: ["import_target_id"] + isOneToOne: false + referencedRelation: "catalogue_import_targets" + referencedColumns: ["id"] + }, + { + foreignKeyName: "catalogue_versions_item_year_fkey" + columns: ["record_id", "academic_year_id"] + isOneToOne: false + referencedRelation: "catalogue_records" + referencedColumns: ["id", "academic_year_id"] + }, + { + foreignKeyName: "catalogue_versions_item_year_fkey" + columns: ["record_id", "academic_year_id"] + isOneToOne: false + referencedRelation: "published_course_summaries" + referencedColumns: ["record_id", "academic_year_id"] + }, + { + foreignKeyName: "catalogue_versions_item_year_kind_fkey" + columns: ["record_id", "kind"] + isOneToOne: false + referencedRelation: "catalogue_records" + referencedColumns: ["id", "kind"] + }, + { + foreignKeyName: "catalogue_versions_source_page_fkey" + columns: ["source_page_id", "academic_year_id"] + isOneToOne: false + referencedRelation: "catalogue_source_pages" + referencedColumns: ["id", "academic_year_id"] + }, + ] + } course_areas_of_interest: { Row: { created_at: string id: number name: string position: number - snapshot_id: number + version_id: number } Insert: { created_at?: string id?: never name: string position: number - snapshot_id: number + version_id: number } Update: { created_at?: string id?: never name?: string position?: number - snapshot_id?: number + version_id?: number } Relationships: [ { foreignKeyName: "course_areas_of_interest_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_areas_of_interest_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -1823,10 +1866,10 @@ export type Database = { id: number learning_outcomes: number[] | null position: number - snapshot_id: number source_text: string title: string updated_at: string + version_id: number weight: number | null } Insert: { @@ -1836,10 +1879,10 @@ export type Database = { id?: never learning_outcomes?: number[] | null position: number - snapshot_id: number source_text: string title: string updated_at?: string + version_id: number weight?: number | null } Update: { @@ -1849,26 +1892,26 @@ export type Database = { id?: never learning_outcomes?: number[] | null position?: number - snapshot_id?: number source_text?: string title?: string updated_at?: string + version_id?: number weight?: number | null } Relationships: [ { foreignKeyName: "course_assessment_items_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_assessment_items_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -1877,56 +1920,55 @@ export type Database = { assessment_item_id: number created_at: string learning_outcome_id: number - snapshot_id: number + version_id: number } Insert: { assessment_item_id: number created_at?: string learning_outcome_id: number - snapshot_id: number + version_id: number } Update: { assessment_item_id?: number created_at?: string learning_outcome_id?: number - snapshot_id?: number + version_id?: number } Relationships: [ { foreignKeyName: "course_assessment_outcomes_assessment_snapshot_fkey" - columns: ["assessment_item_id", "snapshot_id"] + columns: ["assessment_item_id", "version_id"] isOneToOne: false referencedRelation: "course_assessment_items" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, { foreignKeyName: "course_assessment_outcomes_learning_outcome_snapshot_fkey" - columns: ["learning_outcome_id", "snapshot_id"] + columns: ["learning_outcome_id", "version_id"] isOneToOne: false referencedRelation: "course_learning_outcomes" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, { foreignKeyName: "course_assessment_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_assessment_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } course_attempts: { Row: { academic_period_id: number - course_id: number - course_snapshot_id: number + catalogue_version_id: number created_at: string grade: string | null id: string @@ -1940,8 +1982,7 @@ export type Database = { } Insert: { academic_period_id: number - course_id: number - course_snapshot_id: number + catalogue_version_id: number created_at?: string grade?: string | null id?: string @@ -1955,8 +1996,7 @@ export type Database = { } Update: { academic_period_id?: number - course_id?: number - course_snapshot_id?: number + catalogue_version_id?: number created_at?: string grade?: string | null id?: string @@ -1977,32 +2017,18 @@ export type Database = { referencedColumns: ["id"] }, { - foreignKeyName: "course_attempts_course_item_fkey" - columns: ["course_id"] + foreignKeyName: "course_attempts_catalogue_version_fkey" + columns: ["catalogue_version_id"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { - foreignKeyName: "course_attempts_course_item_fkey" - columns: ["course_id"] + foreignKeyName: "course_attempts_catalogue_version_fkey" + columns: ["catalogue_version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_id"] - }, - { - foreignKeyName: "course_attempts_snapshot_fkey" - columns: ["course_snapshot_id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id"] - }, - { - foreignKeyName: "course_attempts_snapshot_fkey" - columns: ["course_snapshot_id"] - isOneToOne: false - referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -2012,42 +2038,42 @@ export type Database = { created_at: string id: number position: number - snapshot_id: number source_text: string value: string + version_id: number } Insert: { attribute_kind: string created_at?: string id?: never position: number - snapshot_id: number source_text: string value: string + version_id: number } Update: { attribute_kind?: string created_at?: string id?: never position?: number - snapshot_id?: number source_text?: string value?: string + version_id?: number } Relationships: [ { foreignKeyName: "course_attributes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_attributes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -2062,10 +2088,10 @@ export type Database = { fee_year: number | null id: number position: number - snapshot_id: number source_label: string | null source_text: string | null student_contribution_band: number | null + version_id: number } Insert: { amount?: number | null @@ -2077,10 +2103,10 @@ export type Database = { fee_year?: number | null id?: never position: number - snapshot_id: number source_label?: string | null source_text?: string | null student_contribution_band?: number | null + version_id: number } Update: { amount?: number | null @@ -2092,25 +2118,25 @@ export type Database = { fee_year?: number | null id?: never position?: number - snapshot_id?: number source_label?: string | null source_text?: string | null student_contribution_band?: number | null + version_id?: number } Relationships: [ { foreignKeyName: "course_fees_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_fees_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -2120,39 +2146,39 @@ export type Database = { created_at: string id: number position: number - snapshot_id: number updated_at: string + version_id: number } Insert: { body: string created_at?: string id?: never position: number - snapshot_id: number updated_at?: string + version_id: number } Update: { body?: string created_at?: string id?: never position?: number - snapshot_id?: number updated_at?: string + version_id?: number } Relationships: [ { foreignKeyName: "course_learning_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_learning_outcomes_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } @@ -2163,9 +2189,9 @@ export type Database = { delivery_mode: string | null id: number location: string | null - snapshot_id: number source_page_id: number | null updated_at: string + version_id: number } Insert: { academic_year_id: number @@ -2173,9 +2199,9 @@ export type Database = { delivery_mode?: string | null id?: never location?: string | null - snapshot_id: number source_page_id?: number | null updated_at?: string + version_id: number } Update: { academic_year_id?: number @@ -2183,30 +2209,30 @@ export type Database = { delivery_mode?: string | null id?: never location?: string | null - snapshot_id?: number source_page_id?: number | null updated_at?: string + version_id?: number } Relationships: [ { foreignKeyName: "course_offerings_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: true - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_offerings_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: true referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, { foreignKeyName: "course_offerings_snapshot_year_fkey" - columns: ["snapshot_id", "academic_year_id"] + columns: ["version_id", "academic_year_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id", "academic_year_id"] }, { @@ -2225,10 +2251,10 @@ export type Database = { position: number related_course_id: number relation_kind: string - snapshot_id: number source_course_code: string source_course_title: string | null source_text: string | null + version_id: number } Insert: { created_at?: string @@ -2236,10 +2262,10 @@ export type Database = { position: number related_course_id: number relation_kind: string - snapshot_id: number source_course_code: string source_course_title?: string | null source_text?: string | null + version_id: number } Update: { created_at?: string @@ -2247,17 +2273,17 @@ export type Database = { position?: number related_course_id?: number relation_kind?: string - snapshot_id?: number source_course_code?: string source_course_title?: string | null source_text?: string | null + version_id?: number } Relationships: [ { foreignKeyName: "course_related_courses_related_item_fkey" columns: ["related_course_id"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id"] }, { @@ -2265,25 +2291,70 @@ export type Database = { columns: ["related_course_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_id"] + referencedColumns: ["code_id"] }, { foreignKeyName: "course_related_courses_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "course_related_courses_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, ] } - course_snapshot_details: { + course_unit_options: { + Row: { + created_at: string + id: number + label: string | null + position: number + source_text: string + units: number + version_id: number + } + Insert: { + created_at?: string + id?: never + label?: string | null + position: number + source_text: string + units: number + version_id: number + } + Update: { + created_at?: string + id?: never + label?: string | null + position?: number + source_text?: string + units?: number + version_id?: number + } + Relationships: [ + { + foreignKeyName: "course_unit_options_snapshot_id_fkey" + columns: ["version_id"] + isOneToOne: false + referencedRelation: "catalogue_versions" + referencedColumns: ["id"] + }, + { + foreignKeyName: "course_unit_options_snapshot_id_fkey" + columns: ["version_id"] + isOneToOne: false + referencedRelation: "published_course_summaries" + referencedColumns: ["version_id"] + }, + ] + } + course_version_details: { Row: { academic_career: string | null college: string | null @@ -2300,13 +2371,13 @@ export type Database = { offering_status: string prescribed_texts: string | null school: string | null - snapshot_id: number source_updated_at: string | null subject_code: string subject_name: string | null title: string unit_value_kind: string units: number | null + version_id: number workload_hours: number | null workload_text: string | null } @@ -2326,13 +2397,13 @@ export type Database = { offering_status?: string prescribed_texts?: string | null school?: string | null - snapshot_id: number source_updated_at?: string | null subject_code: string subject_name?: string | null title: string unit_value_kind?: string units?: number | null + version_id: number workload_hours?: number | null workload_text?: string | null } @@ -2352,71 +2423,26 @@ export type Database = { offering_status?: string prescribed_texts?: string | null school?: string | null - snapshot_id?: number source_updated_at?: string | null subject_code?: string subject_name?: string | null title?: string unit_value_kind?: string units?: number | null + version_id?: number workload_hours?: number | null workload_text?: string | null } Relationships: [ { - foreignKeyName: "course_snapshot_details_snapshot_kind_fkey" - columns: ["snapshot_id", "kind"] + foreignKeyName: "course_version_details_snapshot_kind_fkey" + columns: ["version_id", "kind"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id", "kind"] }, ] } - course_unit_options: { - Row: { - created_at: string - id: number - label: string | null - position: number - snapshot_id: number - source_text: string - units: number - } - Insert: { - created_at?: string - id?: never - label?: string | null - position: number - snapshot_id: number - source_text: string - units: number - } - Update: { - created_at?: string - id?: never - label?: string | null - position?: number - snapshot_id?: number - source_text?: string - units?: number - } - Relationships: [ - { - foreignKeyName: "course_unit_options_snapshot_id_fkey" - columns: ["snapshot_id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id"] - }, - { - foreignKeyName: "course_unit_options_snapshot_id_fkey" - columns: ["snapshot_id"] - isOneToOne: false - referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] - }, - ] - } import_models: { Row: { enabled: boolean @@ -2506,11 +2532,11 @@ export type Database = { id: number location: string | null position: number - snapshot_id: number source_page_id: number | null source_text: string starts_on: string | null updated_at: string + version_id: number } Insert: { academic_period_code: string @@ -2528,11 +2554,11 @@ export type Database = { id?: never location?: string | null position: number - snapshot_id: number source_page_id?: number | null source_text: string starts_on?: string | null updated_at?: string + version_id: number } Update: { academic_period_code?: string @@ -2550,11 +2576,11 @@ export type Database = { id?: never location?: string | null position?: number - snapshot_id?: number source_page_id?: number | null source_text?: string starts_on?: string | null updated_at?: string + version_id?: number } Relationships: [ { @@ -2566,30 +2592,30 @@ export type Database = { }, { foreignKeyName: "offering_sessions_offering_snapshot_fkey" - columns: ["course_offering_id", "snapshot_id"] + columns: ["course_offering_id", "version_id"] isOneToOne: false referencedRelation: "course_offerings" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, { foreignKeyName: "offering_sessions_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id"] }, { foreignKeyName: "offering_sessions_snapshot_id_fkey" - columns: ["snapshot_id"] + columns: ["version_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] + referencedColumns: ["version_id"] }, { foreignKeyName: "offering_sessions_snapshot_year_fkey" - columns: ["snapshot_id", "academic_year_id"] + columns: ["version_id", "academic_year_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id", "academic_year_id"] }, { @@ -2604,8 +2630,7 @@ export type Database = { plan_items: { Row: { academic_period_id: number | null - academic_year_id: number - course_id: number + catalogue_record_id: number created_at: string id: string notes: string | null @@ -2618,8 +2643,7 @@ export type Database = { } Insert: { academic_period_id?: number | null - academic_year_id: number - course_id: number + catalogue_record_id: number created_at?: string id?: string notes?: string | null @@ -2632,8 +2656,7 @@ export type Database = { } Update: { academic_period_id?: number | null - academic_year_id?: number - course_id?: number + catalogue_record_id?: number created_at?: string id?: string notes?: string | null @@ -2653,39 +2676,18 @@ export type Database = { referencedColumns: ["id"] }, { - foreignKeyName: "plan_items_academic_year_calendar_year_fkey" - columns: ["academic_year_id", "planned_calendar_year"] + foreignKeyName: "plan_items_catalogue_record_fkey" + columns: ["catalogue_record_id"] isOneToOne: false - referencedRelation: "academic_years" - referencedColumns: ["id", "year"] - }, - { - foreignKeyName: "plan_items_academic_year_id_fkey" - columns: ["academic_year_id"] - isOneToOne: false - referencedRelation: "academic_years" + referencedRelation: "catalogue_records" referencedColumns: ["id"] }, { - foreignKeyName: "plan_items_course_item_fkey" - columns: ["course_id"] - isOneToOne: false - referencedRelation: "catalogue_items" - referencedColumns: ["id"] - }, - { - foreignKeyName: "plan_items_course_item_fkey" - columns: ["course_id"] + foreignKeyName: "plan_items_catalogue_record_fkey" + columns: ["catalogue_record_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_id"] - }, - { - foreignKeyName: "plan_items_course_item_year_fkey" - columns: ["course_id", "academic_year_id"] - isOneToOne: false - referencedRelation: "catalogue_item_years" - referencedColumns: ["item_id", "academic_year_id"] + referencedColumns: ["record_id"] }, { foreignKeyName: "plan_items_plan_owner_fkey" @@ -2698,59 +2700,49 @@ export type Database = { } plan_structures: { Row: { - academic_year_id: number + catalogue_record_id: number created_at: string id: string owner_id: string plan_id: string position: number role: string - structure_year_id: number updated_at: string } Insert: { - academic_year_id: number + catalogue_record_id: number created_at?: string id?: string owner_id: string plan_id: string position?: number role: string - structure_year_id: number updated_at?: string } Update: { - academic_year_id?: number + catalogue_record_id?: number created_at?: string id?: string owner_id?: string plan_id?: string position?: number role?: string - structure_year_id?: number updated_at?: string } Relationships: [ { - foreignKeyName: "plan_structures_item_year_fkey" - columns: ["structure_year_id", "academic_year_id"] + foreignKeyName: "plan_structures_catalogue_record_fkey" + columns: ["catalogue_record_id"] isOneToOne: false - referencedRelation: "catalogue_item_years" - referencedColumns: ["id", "academic_year_id"] + referencedRelation: "catalogue_records" + referencedColumns: ["id"] }, { - foreignKeyName: "plan_structures_item_year_fkey" - columns: ["structure_year_id", "academic_year_id"] + foreignKeyName: "plan_structures_catalogue_record_fkey" + columns: ["catalogue_record_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_year_id", "academic_year_id"] - }, - { - foreignKeyName: "plan_structures_plan_owner_academic_year_fkey" - columns: ["plan_id", "owner_id", "academic_year_id"] - isOneToOne: false - referencedRelation: "plans" - referencedColumns: ["id", "owner_id", "academic_year_id"] + referencedColumns: ["record_id"] }, ] } @@ -2834,56 +2826,57 @@ export type Database = { requirement_condition_options: { Row: { code: string + code_id: number | null condition_id: number id: number - item_id: number | null kind: string position: number - snapshot_id: number source_text: string | null title: string | null + version_id: number } Insert: { code: string + code_id?: number | null condition_id: number id?: never - item_id?: number | null kind: string position: number - snapshot_id: number source_text?: string | null title?: string | null + version_id: number } Update: { code?: string + code_id?: number | null condition_id?: number id?: never - item_id?: number | null kind?: string position?: number - snapshot_id?: number source_text?: string | null title?: string | null + version_id?: number } Relationships: [ { foreignKeyName: "requirement_condition_options_condition_fkey" - columns: ["condition_id", "snapshot_id"] + columns: ["condition_id", "version_id"] isOneToOne: false referencedRelation: "requirement_conditions" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, { foreignKeyName: "requirement_condition_options_item_fkey" - columns: ["item_id", "kind"] + columns: ["code_id", "kind"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id", "kind"] }, ] } requirement_conditions: { Row: { + code_id: number | null condition_key: string condition_kind: string confidence: number @@ -2891,7 +2884,6 @@ export type Database = { group_id: number hardness: string id: number - item_id: number | null item_kind: string | null maximum_level: number | null maximum_units: number | null @@ -2906,14 +2898,15 @@ export type Database = { requirement_mode: string | null review_state: string rule_id: number - snapshot_id: number source_locator: string | null source_text: string | null structure_kind: string | null subject_code: string | null tag: string | null + version_id: number } Insert: { + code_id?: number | null condition_key: string condition_kind: string confidence?: number @@ -2921,7 +2914,6 @@ export type Database = { group_id: number hardness?: string id?: never - item_id?: number | null item_kind?: string | null maximum_level?: number | null maximum_units?: number | null @@ -2936,14 +2928,15 @@ export type Database = { requirement_mode?: string | null review_state?: string rule_id: number - snapshot_id: number source_locator?: string | null source_text?: string | null structure_kind?: string | null subject_code?: string | null tag?: string | null + version_id: number } Update: { + code_id?: number | null condition_key?: string condition_kind?: string confidence?: number @@ -2951,7 +2944,6 @@ export type Database = { group_id?: number hardness?: string id?: never - item_id?: number | null item_kind?: string | null maximum_level?: number | null maximum_units?: number | null @@ -2966,12 +2958,12 @@ export type Database = { requirement_mode?: string | null review_state?: string rule_id?: number - snapshot_id?: number source_locator?: string | null source_text?: string | null structure_kind?: string | null subject_code?: string | null tag?: string | null + version_id?: number } Relationships: [ { @@ -2983,17 +2975,17 @@ export type Database = { }, { foreignKeyName: "requirement_conditions_item_fkey" - columns: ["item_id", "item_kind"] + columns: ["code_id", "item_kind"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id", "kind"] }, { foreignKeyName: "requirement_conditions_rule_fkey" - columns: ["rule_id", "snapshot_id"] + columns: ["rule_id", "version_id"] isOneToOne: false referencedRelation: "requirement_rules" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, ] } @@ -3010,9 +3002,9 @@ export type Database = { parent_group_id: number | null position: number rule_id: number - snapshot_id: number source_locator: string | null source_text: string | null + version_id: number } Insert: { description?: string | null @@ -3026,9 +3018,9 @@ export type Database = { parent_group_id?: number | null position?: number rule_id: number - snapshot_id: number source_locator?: string | null source_text?: string | null + version_id: number } Update: { description?: string | null @@ -3042,9 +3034,9 @@ export type Database = { parent_group_id?: number | null position?: number rule_id?: number - snapshot_id?: number source_locator?: string | null source_text?: string | null + version_id?: number } Relationships: [ { @@ -3056,62 +3048,62 @@ export type Database = { }, { foreignKeyName: "requirement_groups_rule_fkey" - columns: ["rule_id", "snapshot_id"] + columns: ["rule_id", "version_id"] isOneToOne: false referencedRelation: "requirement_rules" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, ] } requirement_item_references: { Row: { + code_id: number confidence: number id: number - item_id: number review_state: string rule_id: number - snapshot_id: number source_text: string + version_id: number } Insert: { + code_id: number confidence?: number id?: never - item_id: number review_state?: string rule_id: number - snapshot_id: number source_text: string + version_id: number } Update: { + code_id?: number confidence?: number id?: never - item_id?: number review_state?: string rule_id?: number - snapshot_id?: number source_text?: string + version_id?: number } Relationships: [ { foreignKeyName: "requirement_item_references_item_fkey" - columns: ["item_id"] + columns: ["code_id"] isOneToOne: false - referencedRelation: "catalogue_items" + referencedRelation: "catalogue_codes" referencedColumns: ["id"] }, { foreignKeyName: "requirement_item_references_item_fkey" - columns: ["item_id"] + columns: ["code_id"] isOneToOne: false referencedRelation: "published_course_summaries" - referencedColumns: ["item_id"] + referencedColumns: ["code_id"] }, { foreignKeyName: "requirement_item_references_rule_fkey" - columns: ["rule_id", "snapshot_id"] + columns: ["rule_id", "version_id"] isOneToOne: false referencedRelation: "requirement_rules" - referencedColumns: ["id", "snapshot_id"] + referencedColumns: ["id", "version_id"] }, ] } @@ -3125,10 +3117,10 @@ export type Database = { position: number review_state: string rule_kind: string - snapshot_id: number source_locator: string | null source_page_id: number | null source_text: string + version_id: number } Insert: { academic_year_id: number @@ -3139,10 +3131,10 @@ export type Database = { position?: number review_state?: string rule_kind: string - snapshot_id: number source_locator?: string | null source_page_id?: number | null source_text: string + version_id: number } Update: { academic_year_id?: number @@ -3153,17 +3145,17 @@ export type Database = { position?: number review_state?: string rule_kind?: string - snapshot_id?: number source_locator?: string | null source_page_id?: number | null source_text?: string + version_id?: number } Relationships: [ { foreignKeyName: "requirement_rules_snapshot_fkey" - columns: ["snapshot_id", "academic_year_id"] + columns: ["version_id", "academic_year_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id", "academic_year_id"] }, { @@ -3175,61 +3167,52 @@ export type Database = { }, ] } - snapshot_field_evidence: { + structure_snapshot_summary_fields: { Row: { - academic_year_id: number - confidence: number | null - created_at: string - field_path: string - id: number - method: string - snapshot_id: number - source_excerpt: string | null - source_locator: string | null - source_page_id: number | null + field_key: string + field_value: string + label: string + position: number + source_text: string + value_position: number + version_id: number } Insert: { - academic_year_id: number - confidence?: number | null - created_at?: string - field_path: string - id?: never - method: string - snapshot_id: number - source_excerpt?: string | null - source_locator?: string | null - source_page_id?: number | null + field_key: string + field_value: string + label: string + position: number + source_text: string + value_position: number + version_id: number } Update: { - academic_year_id?: number - confidence?: number | null - created_at?: string - field_path?: string - id?: never - method?: string - snapshot_id?: number - source_excerpt?: string | null - source_locator?: string | null - source_page_id?: number | null + field_key?: string + field_value?: string + label?: string + position?: number + source_text?: string + value_position?: number + version_id?: number } Relationships: [ { - foreignKeyName: "snapshot_field_evidence_snapshot_fkey" - columns: ["snapshot_id", "academic_year_id"] + foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id", "academic_year_id"] + referencedRelation: "catalogue_versions" + referencedColumns: ["id"] }, { - foreignKeyName: "snapshot_field_evidence_source_page_fkey" - columns: ["source_page_id", "academic_year_id"] + foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" + columns: ["version_id"] isOneToOne: false - referencedRelation: "catalogue_source_pages" - referencedColumns: ["id", "academic_year_id"] + referencedRelation: "published_course_summaries" + referencedColumns: ["version_id"] }, ] } - structure_snapshot_details: { + structure_version_details: { Row: { academic_career: string | null acronym: string | null @@ -3246,9 +3229,9 @@ export type Database = { name: string selection_rank: number | null short_name: string | null - snapshot_id: number study_as: string | null units: number | null + version_id: number } Insert: { academic_career?: string | null @@ -3266,9 +3249,9 @@ export type Database = { name: string selection_rank?: number | null short_name?: string | null - snapshot_id: number study_as?: string | null units?: number | null + version_id: number } Update: { academic_career?: string | null @@ -3286,65 +3269,20 @@ export type Database = { name?: string selection_rank?: number | null short_name?: string | null - snapshot_id?: number study_as?: string | null units?: number | null + version_id?: number } Relationships: [ { - foreignKeyName: "structure_snapshot_details_snapshot_kind_fkey" - columns: ["snapshot_id", "kind"] + foreignKeyName: "structure_version_details_snapshot_kind_fkey" + columns: ["version_id", "kind"] isOneToOne: false - referencedRelation: "catalogue_snapshots" + referencedRelation: "catalogue_versions" referencedColumns: ["id", "kind"] }, ] } - structure_snapshot_summary_fields: { - Row: { - field_key: string - field_value: string - label: string - position: number - snapshot_id: number - source_text: string - value_position: number - } - Insert: { - field_key: string - field_value: string - label: string - position: number - snapshot_id: number - source_text: string - value_position: number - } - Update: { - field_key?: string - field_value?: string - label?: string - position?: number - snapshot_id?: number - source_text?: string - value_position?: number - } - Relationships: [ - { - foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" - columns: ["snapshot_id"] - isOneToOne: false - referencedRelation: "catalogue_snapshots" - referencedColumns: ["id"] - }, - { - foreignKeyName: "structure_snapshot_summary_fields_snapshot_fkey" - columns: ["snapshot_id"] - isOneToOne: false - referencedRelation: "published_course_summaries" - referencedColumns: ["snapshot_id"] - }, - ] - } university_calendar_events: { Row: { academic_year_id: number @@ -3567,6 +3505,7 @@ export type Database = { academic_year: number | null academic_year_id: number | null code: string | null + code_id: number | null college: string | null convener_text: string | null delivery_summary: string | null @@ -3574,27 +3513,26 @@ export type Database = { eftsl: number | null inherent_requirements: string | null introduction: string | null - item_id: number | null - item_year_id: number | null level: number | null maximum_units: number | null minimum_units: number | null offering_status: string | null prescribed_texts: string | null + record_id: number | null school: string | null - snapshot_id: number | null source_updated_at: string | null subject_code: string | null subject_name: string | null title: string | null unit_value_kind: string | null units: number | null + version_id: number | null workload_hours: number | null workload_text: string | null } Relationships: [ { - foreignKeyName: "catalogue_item_years_academic_year_id_fkey" + foreignKeyName: "catalogue_records_academic_year_id_fkey" columns: ["academic_year_id"] isOneToOne: false referencedRelation: "academic_years" @@ -3613,20 +3551,20 @@ export type Database = { } Returns: string } - admin_snapshot_projection: { - Args: { p_snapshot_id: number } + admin_catalogue_version_projection: { + Args: { p_version_id: number } Returns: Json } cancel_catalogue_import: { Args: { p_run_id: string }; Returns: number } catalogue_publish_blockers: { - Args: { p_item_year_id: number } + Args: { p_record_id: number } Returns: string[] } - current_user_course_attempt_snapshot_projections: { - Args: { p_snapshot_ids: number[] } + current_user_course_attempt_version_projections: { + Args: { p_version_ids: number[] } Returns: { projection: Json - snapshot_id: number + version_id: number }[] } current_user_has_permission: { @@ -3637,10 +3575,6 @@ export type Database = { Args: { p_code: string; p_kind: string } Returns: undefined } - discard_catalogue_draft: { - Args: { p_item_year_id: number } - Returns: undefined - } mark_notifications_read: { Args: { p_notification_ids?: string[] } Returns: number @@ -3654,8 +3588,8 @@ export type Database = { } Returns: undefined } - publish_catalogue_snapshot: { - Args: { p_item_year_id: number } + publish_catalogue_version: { + Args: { p_record_id: number } Returns: number } published_course_availability: { @@ -3667,7 +3601,7 @@ export type Database = { course_year_id: number is_available: boolean offering_status: string - published_snapshot_id: number + published_version_id: number }[] } published_course_detail: { @@ -3764,8 +3698,8 @@ export type Database = { } Returns: Json } - unpublish_catalogue_item_year: { - Args: { p_item_year_id: number } + unpublish_catalogue_record: { + Args: { p_record_id: number } Returns: undefined } } diff --git a/apps/web/ui/admin/catalogue/catalogue-directory.tsx b/apps/web/ui/admin/catalogue/catalogue-directory.tsx index 8fe462a1..042533a1 100644 --- a/apps/web/ui/admin/catalogue/catalogue-directory.tsx +++ b/apps/web/ui/admin/catalogue/catalogue-directory.tsx @@ -385,7 +385,7 @@ export function CatalogueDirectory({ const busy = record.workflow === "queued" || record.workflow === "running"; const checked = selected.includes(record.code); - const href = record.itemYearPublicId + const href = record.recordPublicId ? `${basePath}/${record.code}?year=${page.academicYear}` : undefined; return ( diff --git a/apps/web/ui/admin/catalogue/import-runs.tsx b/apps/web/ui/admin/catalogue/import-runs.tsx index 43b84055..1e6dbc89 100644 --- a/apps/web/ui/admin/catalogue/import-runs.tsx +++ b/apps/web/ui/admin/catalogue/import-runs.tsx @@ -439,7 +439,7 @@ function RecordRow({ record.errorMessage || (record.errorCode ? readable(record.errorCode) : null); const reviewHref = - record.status === "ready" && record.itemYearPublicId + record.status === "ready" && record.recordPublicId ? `${basePath}/${record.code}?year=${record.academicYear}&tab=review` : undefined; return ( @@ -459,7 +459,7 @@ function RecordRow({
{/* Why it failed, so a column of "Failed" badges can be told apart without opening each pipeline in turn. The message is written for @@ -512,7 +512,7 @@ function RecordRow({ ...(reviewHref ? [{ label: "Review import", href: reviewHref }] : []), - ...(record.itemYearPublicId + ...(record.recordPublicId ? [ { label: "Import history", diff --git a/apps/web/ui/admin/catalogue/record-header.tsx b/apps/web/ui/admin/catalogue/record-header.tsx index cc57eaef..4f8b7f71 100644 --- a/apps/web/ui/admin/catalogue/record-header.tsx +++ b/apps/web/ui/admin/catalogue/record-header.tsx @@ -46,11 +46,11 @@ export function RecordHeader({ const labels = CATALOGUE_KIND_LABELS[record.kind]; const step = recordNextStep(record); const workflow = - record.publishedSnapshotId && record.draftSnapshotId + record.publishedVersionId && record.currentVersionId ? "published_with_draft" - : record.publishedSnapshotId + : record.publishedVersionId ? "published" - : record.draftSnapshotId + : record.currentVersionId ? "draft" : "not_imported"; @@ -93,16 +93,14 @@ export function RecordHeader({
- {record.publishedSnapshotId ? ( + {record.publishedVersionId ? ( - run(() => - unpublishAction({ itemYearId: record.itemYearId, path }), - ) + run(() => unpublishAction({ recordId: record.recordId, path })) } trigger={ - } - /> - ) : snapshot.id !== record.publishedSnapshotId || - record.draftSnapshotId ? ( + {snapshot.id !== record.currentVersionId ? (