From 9d94fcd9f96b0a0b7304f5662bfb5b8d286c3b9f Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Tue, 22 Sep 2026 13:22:32 +1000 Subject: [PATCH] feat: show an administrator the page a student will get Student view rendered the published version and nothing else, so the one question it exists to answer, what students will see if I publish this, could not be asked. It now leads with the draft and offers Published beside it when there is one, with no dead control when only one exists. A draft reaches the reader by being projected into the shape the database projection returns, so unpublished content travels the published mapping rather than a second one that would drift from it. Publication now drops the cached public reads for the record. They are cached for five minutes by tag, nothing invalidated them, and the tags were spelled out at each call site, so publishing left students on the previous version and nothing in the code connected the two halves. Both sides build the tags in one place. The public catalogue is year-first only. /structures/[code], which chose a year for the reader, is gone, and the requirement options that linked to it now address the year they belong to. The detail clients move out of the deleted route into ui/, and the year-first record routes get the loading and error boundaries that route had and they lacked. --- apps/web/app/courses/[year]/[code]/error.tsx | 7 + .../web/app/courses/[year]/[code]/loading.tsx | 5 + apps/web/app/majors/[year]/[code]/error.tsx | 7 + apps/web/app/majors/[year]/[code]/loading.tsx | 5 + apps/web/app/minors/[year]/[code]/error.tsx | 7 + apps/web/app/minors/[year]/[code]/loading.tsx | 5 + .../app/programmes/[year]/[code]/error.tsx | 7 + .../app/programmes/[year]/[code]/loading.tsx | 5 + .../specialisations/[year]/[code]/error.tsx | 7 + .../specialisations/[year]/[code]/loading.tsx | 5 + apps/web/app/structures/[code]/error.tsx | 7 - apps/web/app/structures/[code]/page.tsx | 62 -------- .../lib/coursemap/admin-catalogue-actions.ts | 14 ++ apps/web/lib/coursemap/course-version-view.ts | 111 +++++++++++++++ apps/web/lib/coursemap/published-cache.ts | 63 ++++++++ apps/web/lib/coursemap/published-courses.ts | 34 ++++- .../web/lib/coursemap/published-structures.ts | 36 +---- .../catalogue-student-view-panel.test.tsx | 96 +++++++++++++ apps/web/tests/catalogue-student-view.test.ts | 134 ++++++++++++++++++ apps/web/tests/published-cache.test.ts | 30 ++++ .../web/ui/admin/catalogue/content-editor.tsx | 9 ++ apps/web/ui/admin/catalogue/record-page.tsx | 34 +++-- .../ui/admin/catalogue/student-view-panel.tsx | 78 ++++++++++ .../public-record-error.tsx} | 28 ++-- .../catalogue/public-record-loading.tsx} | 5 +- apps/web/ui/catalogue/public-record-page.tsx | 45 +----- .../courses}/course-detail-client.tsx | 0 .../ui/requirements/requirement-condition.tsx | 66 ++++++--- .../requirements}/structure-detail-client.tsx | 1 - docs/architecture.md | 5 +- docs/catalogue-completion-plan.md | 10 +- docs/catalogue-operations.md | 21 ++- .../tests/database/public_catalogue_reads.sql | 116 +++++++++++++++ 33 files changed, 872 insertions(+), 193 deletions(-) create mode 100644 apps/web/app/courses/[year]/[code]/error.tsx create mode 100644 apps/web/app/courses/[year]/[code]/loading.tsx create mode 100644 apps/web/app/majors/[year]/[code]/error.tsx create mode 100644 apps/web/app/majors/[year]/[code]/loading.tsx create mode 100644 apps/web/app/minors/[year]/[code]/error.tsx create mode 100644 apps/web/app/minors/[year]/[code]/loading.tsx create mode 100644 apps/web/app/programmes/[year]/[code]/error.tsx create mode 100644 apps/web/app/programmes/[year]/[code]/loading.tsx create mode 100644 apps/web/app/specialisations/[year]/[code]/error.tsx create mode 100644 apps/web/app/specialisations/[year]/[code]/loading.tsx delete mode 100644 apps/web/app/structures/[code]/error.tsx delete mode 100644 apps/web/app/structures/[code]/page.tsx create mode 100644 apps/web/lib/coursemap/course-version-view.ts create mode 100644 apps/web/lib/coursemap/published-cache.ts create mode 100644 apps/web/tests/catalogue-student-view-panel.test.tsx create mode 100644 apps/web/tests/catalogue-student-view.test.ts create mode 100644 apps/web/tests/published-cache.test.ts create mode 100644 apps/web/ui/admin/catalogue/student-view-panel.tsx rename apps/web/ui/{requirements/structure-catalogue-error.tsx => catalogue/public-record-error.tsx} (59%) rename apps/web/{app/structures/[code]/loading.tsx => ui/catalogue/public-record-loading.tsx} (86%) rename apps/web/{app/courses/[code] => ui/courses}/course-detail-client.tsx (100%) rename apps/web/{app/structures/[code] => ui/requirements}/structure-detail-client.tsx (97%) create mode 100644 supabase/tests/database/public_catalogue_reads.sql diff --git a/apps/web/app/courses/[year]/[code]/error.tsx b/apps/web/app/courses/[year]/[code]/error.tsx new file mode 100644 index 00000000..2033d129 --- /dev/null +++ b/apps/web/app/courses/[year]/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PublicRecordError } from "@/ui/catalogue/public-record-error"; + +export default function Error({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/courses/[year]/[code]/loading.tsx b/apps/web/app/courses/[year]/[code]/loading.tsx new file mode 100644 index 00000000..4902016b --- /dev/null +++ b/apps/web/app/courses/[year]/[code]/loading.tsx @@ -0,0 +1,5 @@ +import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/majors/[year]/[code]/error.tsx b/apps/web/app/majors/[year]/[code]/error.tsx new file mode 100644 index 00000000..e764c0b3 --- /dev/null +++ b/apps/web/app/majors/[year]/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PublicRecordError } from "@/ui/catalogue/public-record-error"; + +export default function Error({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/majors/[year]/[code]/loading.tsx b/apps/web/app/majors/[year]/[code]/loading.tsx new file mode 100644 index 00000000..46bcf8ba --- /dev/null +++ b/apps/web/app/majors/[year]/[code]/loading.tsx @@ -0,0 +1,5 @@ +import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/minors/[year]/[code]/error.tsx b/apps/web/app/minors/[year]/[code]/error.tsx new file mode 100644 index 00000000..30fbede3 --- /dev/null +++ b/apps/web/app/minors/[year]/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PublicRecordError } from "@/ui/catalogue/public-record-error"; + +export default function Error({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/minors/[year]/[code]/loading.tsx b/apps/web/app/minors/[year]/[code]/loading.tsx new file mode 100644 index 00000000..823bf239 --- /dev/null +++ b/apps/web/app/minors/[year]/[code]/loading.tsx @@ -0,0 +1,5 @@ +import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/programmes/[year]/[code]/error.tsx b/apps/web/app/programmes/[year]/[code]/error.tsx new file mode 100644 index 00000000..65ba9ab6 --- /dev/null +++ b/apps/web/app/programmes/[year]/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PublicRecordError } from "@/ui/catalogue/public-record-error"; + +export default function Error({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/programmes/[year]/[code]/loading.tsx b/apps/web/app/programmes/[year]/[code]/loading.tsx new file mode 100644 index 00000000..dd2741be --- /dev/null +++ b/apps/web/app/programmes/[year]/[code]/loading.tsx @@ -0,0 +1,5 @@ +import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/specialisations/[year]/[code]/error.tsx b/apps/web/app/specialisations/[year]/[code]/error.tsx new file mode 100644 index 00000000..32d4200f --- /dev/null +++ b/apps/web/app/specialisations/[year]/[code]/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PublicRecordError } from "@/ui/catalogue/public-record-error"; + +export default function Error({ reset }: { reset: () => void }) { + return ; +} diff --git a/apps/web/app/specialisations/[year]/[code]/loading.tsx b/apps/web/app/specialisations/[year]/[code]/loading.tsx new file mode 100644 index 00000000..94bb7ad0 --- /dev/null +++ b/apps/web/app/specialisations/[year]/[code]/loading.tsx @@ -0,0 +1,5 @@ +import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading"; + +export default function Loading() { + return ; +} diff --git a/apps/web/app/structures/[code]/error.tsx b/apps/web/app/structures/[code]/error.tsx deleted file mode 100644 index c2edc48a..00000000 --- a/apps/web/app/structures/[code]/error.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error"; - -export default function StructureError({ reset }: { reset: () => void }) { - return ; -} diff --git a/apps/web/app/structures/[code]/page.tsx b/apps/web/app/structures/[code]/page.tsx deleted file mode 100644 index 5cf9bd4e..00000000 --- a/apps/web/app/structures/[code]/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { notFound } from "next/navigation"; - -import { requirementCourseCodes } from "@/lib/coursemap/requirement-display"; -import { planCourseFromDetails } from "@/lib/coursemap/plan-catalogue"; -import { loadPublishedCoursesByCodes } from "@/lib/coursemap/published-courses"; -import { - loadPublishedStructure, - loadPublishedStructureYears, -} from "@/lib/coursemap/published-structures"; -import type { Course } from "@/lib/coursemap/types"; -import { StructureCatalogueError } from "@/ui/requirements/structure-catalogue-error"; -import { StructureDetailClient } from "./structure-detail-client"; - -export default async function StructurePage({ - params, - searchParams, -}: { - params: Promise<{ code: string }>; - searchParams: Promise<{ year?: string | string[] }>; -}) { - const { code } = await params; - const requestedYearParam = (await searchParams).year; - const requestedYear = Number( - Array.isArray(requestedYearParam) - ? requestedYearParam[0] - : requestedYearParam, - ); - - let structure = null; - let courses: Course[] = []; - try { - const years = await loadPublishedStructureYears(code); - if (years.length === 0) notFound(); - const thisYear = new Date().getFullYear(); - const academicYear = years.includes(requestedYear) - ? requestedYear - : years.includes(thisYear) - ? thisYear - : years[0]; - structure = await loadPublishedStructure(code, academicYear); - if (structure) { - // The option cards read better with a title and a unit value, so the - // courses the tree names are resolved once here rather than per card. - const details = await loadPublishedCoursesByCodes( - requirementCourseCodes(structure.requirements), - academicYear, - ); - courses = details.map(planCourseFromDetails); - } - } catch { - return ( - - ); - } - - if (!structure) notFound(); - return ; -} - -export const dynamic = "force-dynamic"; diff --git a/apps/web/lib/coursemap/admin-catalogue-actions.ts b/apps/web/lib/coursemap/admin-catalogue-actions.ts index 20795bd5..44e1c1b3 100644 --- a/apps/web/lib/coursemap/admin-catalogue-actions.ts +++ b/apps/web/lib/coursemap/admin-catalogue-actions.ts @@ -13,8 +13,16 @@ import { unpublishCatalogueRecord, } from "@/lib/catalogue/drafts"; import { resolveSourceChange } from "@/lib/catalogue/source-review-decisions"; +import type { CatalogueKind } from "@/lib/coursemap/catalogue-kinds"; +import { revalidatePublishedRecord } from "@/lib/coursemap/published-cache"; import type { SourceReviewDecision } from "@/lib/catalogue/source-review-store"; +type PublishedRecord = { + kind: CatalogueKind; + academicYear: number; + code: string; +}; + export type ActionResult = { ok: true; message?: string } | { ok: false; error: string }; @@ -59,11 +67,13 @@ export async function publishDraftAction({ expectedRevision, editingSessionId, path, + record, }: { recordId: number; expectedRevision: number; editingSessionId: string; path: string; + record: PublishedRecord; }): Promise { if (!(await canWriteCatalogue())) return { ok: false, error: "Catalogue write permission is required." }; @@ -77,6 +87,7 @@ export async function publishDraftAction({ userId: viewer.id, }); revalidateRecord(path); + revalidatePublishedRecord(record); return { ok: true, message: "Published. Students now see this version." }; } catch (error) { return draftFailure(error, "The draft could not be published."); @@ -87,10 +98,12 @@ export async function unpublishAction({ recordId, editingSessionId, path, + record, }: { recordId: number; editingSessionId: string; path: string; + record: PublishedRecord; }): Promise { if (!(await canWriteCatalogue())) return { ok: false, error: "Catalogue write permission is required." }; @@ -103,6 +116,7 @@ export async function unpublishAction({ userId: viewer.id, }); revalidateRecord(path); + revalidatePublishedRecord(record); return { ok: true, message: "Unpublished. Students no longer see this record for the year.", diff --git a/apps/web/lib/coursemap/course-version-view.ts b/apps/web/lib/coursemap/course-version-view.ts new file mode 100644 index 00000000..8beb811b --- /dev/null +++ b/apps/web/lib/coursemap/course-version-view.ts @@ -0,0 +1,111 @@ +import type { CatalogueContent } from "@/lib/catalogue/content"; +import type { CourseDetails } from "@/lib/coursemap/course-types"; +import { courseFromDraftProjection } from "@/lib/coursemap/published-courses"; +import type { Json } from "@/types/database"; + +/** + * Course content in the shape `private.course_version_projection` returns, so + * unpublished content reaches the reader through the same mapping the + * published page uses rather than a second one that can drift from it. + * + * The reverse-lookup keys the database adds, such as `prerequisiteEdges`, are + * deliberately absent: they are computed over published courses only, and + * their absence is how the view knows the question was never asked. + */ +function courseProjectionFromWrite(write: CatalogueContent): Json { + const course = write.course; + if (!course) return null; + const requirements = write.requirements; + return { + courseCode: write.code, + academicYear: write.academicYear, + origin: "manual", + snapshot: course.details as unknown as Json, + unitOptions: course.unitOptions as unknown as Json, + fees: course.fees as unknown as Json, + areasOfInterest: course.areasOfInterest as unknown as Json, + attributes: course.attributes as unknown as Json, + relatedCourses: course.relatedCourses as unknown as Json, + courseOffering: (course.offering ?? null) as unknown as Json, + offeringSessions: course.sessions as unknown as Json, + learningOutcomes: course.learningOutcomes as unknown as Json, + assessmentItems: course.assessmentItems as unknown as Json, + assessmentOutcomes: course.assessmentOutcomes as unknown as Json, + rules: requirements.rules.map((rule) => ({ + key: rule.key, + ruleKind: rule.key, + hardness: rule.hardness, + sourceText: rule.sourceText, + reviewState: rule.reviewState, + confidence: rule.confidence, + })), + ruleGroups: requirements.groups.map((group) => ({ + key: group.key, + ruleKey: group.ruleKey, + parentGroupKey: group.parentKey, + operator: group.operator, + minimumCount: group.minimumCount, + minimumUnits: group.minimumUnits, + maximumUnits: group.maximumUnits, + label: group.label, + description: group.description, + sourceText: group.sourceText, + position: group.position, + })), + ruleConditions: requirements.conditions.map((condition) => ({ + key: condition.key, + ruleKey: condition.ruleKey, + groupKey: condition.groupKey, + position: condition.position, + conditionKind: condition.kind, + requiredCourseCode: + condition.kind === "course" || condition.kind === "incompatible" + ? condition.itemCode + : null, + requiredStructureCode: + condition.kind === "structure" ? condition.itemCode : null, + structureKind: condition.structureKind, + minimumUnits: condition.minimumUnits, + maximumUnits: condition.maximumUnits, + minimumCount: condition.minimumCount, + minimumMark: condition.minimumMark, + subjectCode: condition.subjectCode, + minimumCourseLevel: condition.minimumLevel, + maximumCourseLevel: condition.maximumLevel, + minimumGpa: condition.minimumGpa, + minimumYear: condition.minimumYear, + minimumWam: condition.minimumWam, + tag: condition.tag, + freeText: condition.freeText, + courseRequirementMode: condition.requirementMode, + hardness: condition.hardness, + sourceText: condition.sourceText, + reviewState: condition.reviewState, + confidence: condition.confidence, + })), + ruleConditionCourses: requirements.options.map((option) => ({ + conditionKey: option.conditionKey, + position: option.position, + kind: option.kind, + sourceCourseCode: option.code, + title: option.title, + sourceText: option.sourceText, + })), + ruleCourseReferences: requirements.references.map((reference) => ({ + ruleKey: reference.ruleKey, + referencedCourseCode: reference.code, + sourceText: reference.sourceText, + reviewState: reference.reviewState, + confidence: reference.confidence, + })), + sourceUpdatedAt: course.details.sourceUpdatedAt, + } as unknown as Json; +} + +/** The reader's view of course content that has not been published yet. */ +export function courseDetailsFromWrite( + write: CatalogueContent, +): CourseDetails | null { + if (write.kind !== "course") return null; + return courseFromDraftProjection(courseProjectionFromWrite(write)); +} diff --git a/apps/web/lib/coursemap/published-cache.ts b/apps/web/lib/coursemap/published-cache.ts new file mode 100644 index 00000000..8fb0ff0f --- /dev/null +++ b/apps/web/lib/coursemap/published-cache.ts @@ -0,0 +1,63 @@ +import { updateTag } from "next/cache"; +import type { CatalogueKind } from "@/lib/catalogue/content"; + +/** + * Cache tags for the published reads. The loaders and the invalidation both + * build them here, because a tag that only one side knows about is a public + * page that never notices it was republished. + */ +export const PUBLISHED_COURSE_DETAIL_TAG = "published-course-detail"; +export const PUBLISHED_COURSE_PAGE_TAG = "published-course-page"; +export const PUBLISHED_COURSE_YEARS_TAG = "published-course-years"; +export const PUBLISHED_STRUCTURE_DETAIL_TAG = "published-structure-detail"; + +export function publishedCourseYearTag(academicYear: number) { + return `published-courses:${academicYear}`; +} + +export function publishedCourseTag(academicYear: number, code: string) { + return `published-course:${academicYear}:${code.trim().toUpperCase()}`; +} + +export function publishedStructureTag(academicYear: number, code: string) { + return `published-structure:${academicYear}:${code.trim().toUpperCase()}`; +} + +/** Every tag a change to one record's publication state invalidates. */ +export function publishedRecordTags({ + kind, + academicYear, + code, +}: { + kind: CatalogueKind; + academicYear: number; + code: string; +}) { + if (kind !== "course") { + return [ + PUBLISHED_STRUCTURE_DETAIL_TAG, + publishedStructureTag(academicYear, code), + ]; + } + return [ + PUBLISHED_COURSE_DETAIL_TAG, + PUBLISHED_COURSE_PAGE_TAG, + PUBLISHED_COURSE_YEARS_TAG, + publishedCourseTag(academicYear, code), + publishedCourseYearTag(academicYear), + ]; +} + +/** + * Drops the cached public reads for one record. `updateTag` rather than + * `revalidateTag` because publication happens in a server action and the + * administrator should be able to open the public page and see the version + * they just published, not the one that was cached five minutes ago. + */ +export function revalidatePublishedRecord(record: { + kind: CatalogueKind; + academicYear: number; + code: string; +}) { + for (const tag of publishedRecordTags(record)) updateTag(tag); +} diff --git a/apps/web/lib/coursemap/published-courses.ts b/apps/web/lib/coursemap/published-courses.ts index 9cd8c11b..ea80c8d6 100644 --- a/apps/web/lib/coursemap/published-courses.ts +++ b/apps/web/lib/coursemap/published-courses.ts @@ -1,3 +1,10 @@ +import { + PUBLISHED_COURSE_DETAIL_TAG, + PUBLISHED_COURSE_PAGE_TAG, + PUBLISHED_COURSE_YEARS_TAG, + publishedCourseTag, + publishedCourseYearTag, +} from "./published-cache"; import "server-only"; import { unstable_cache } from "next/cache"; import type { SupabaseClient } from "@supabase/supabase-js"; @@ -892,6 +899,20 @@ function detailAsCourseDetails( }; } +/** + * The reader's view of course content with no version behind it, such as a + * mutable draft. Identical to a published read apart from saying so, which is + * what lets one preview component answer "what will students see". + */ +export function courseFromDraftProjection( + projection: Json, +): CourseDetails | null { + const details = detailAsCourseDetails(projection); + return details === null + ? null + : { ...details, publicationStatus: "draft" as const }; +} + export function courseFromSnapshotProjection( projection: Json, snapshotId: number, @@ -945,7 +966,7 @@ export async function loadAcademicYearOptions(): Promise { ["published-academic-year-options"], { revalidate: 300, - tags: ["published-course-years"], + tags: [PUBLISHED_COURSE_YEARS_TAG], }, )(); } @@ -1334,7 +1355,10 @@ export async function loadPublishedCoursePage(args: { ], { revalidate: 300, - tags: ["published-course-page", `published-courses:${args.academicYear}`], + tags: [ + PUBLISHED_COURSE_PAGE_TAG, + publishedCourseYearTag(args.academicYear), + ], }, )(); } @@ -1500,9 +1524,9 @@ export async function loadPublishedCourse( { revalidate: 300, tags: [ - "published-course-detail", - `published-course:${academicYear}:${normalisedCode}`, - `published-courses:${academicYear}`, + PUBLISHED_COURSE_DETAIL_TAG, + publishedCourseTag(academicYear, normalisedCode), + publishedCourseYearTag(academicYear), ], }, )(); diff --git a/apps/web/lib/coursemap/published-structures.ts b/apps/web/lib/coursemap/published-structures.ts index 2dff56eb..555fc237 100644 --- a/apps/web/lib/coursemap/published-structures.ts +++ b/apps/web/lib/coursemap/published-structures.ts @@ -1,3 +1,7 @@ +import { + PUBLISHED_STRUCTURE_DETAIL_TAG, + publishedStructureTag, +} from "./published-cache"; import "server-only"; import { unstable_cache } from "next/cache"; import { createPublicClient } from "@/lib/supabase/public-server"; @@ -215,36 +219,8 @@ export async function loadPublishedStructure( { revalidate: 300, tags: [ - "published-structure-detail", - `published-structure:${academicYear}:${normalisedCode}`, - ], - }, - )(); -} - -/** Published years for a structure code, newest first. */ -export async function loadPublishedStructureYears( - code: string, -): Promise { - const normalisedCode = code.trim().toUpperCase(); - if (!STRUCTURE_CODE_PATTERN.test(normalisedCode)) return []; - return unstable_cache( - async () => { - const client = createPublicClient() as unknown as LooseRpcClient; - const { data, error } = await client.rpc("published_structure_years", { - p_structure_code: normalisedCode, - }); - if (error) throw new Error(error.message); - return readRecords(data ?? []).map((row) => - readNumber(row.academic_year), - ); - }, - ["published-structure-years", normalisedCode], - { - revalidate: 300, - tags: [ - "published-structure-years", - `published-structure:${normalisedCode}`, + PUBLISHED_STRUCTURE_DETAIL_TAG, + publishedStructureTag(academicYear, normalisedCode), ], }, )(); diff --git a/apps/web/tests/catalogue-student-view-panel.test.tsx b/apps/web/tests/catalogue-student-view-panel.test.tsx new file mode 100644 index 00000000..19d4832d --- /dev/null +++ b/apps/web/tests/catalogue-student-view-panel.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import type { CourseDetails } from "@/lib/coursemap/course-types"; +import { + StudentViewPanel, + type StudentPreviewSource, +} from "@/ui/admin/catalogue/student-view-panel"; + +vi.mock("@/ui/admin/catalogue/version-preview", () => ({ + CoursePreview: ({ course }: { course: CourseDetails }) => ( +
{course.description}
+ ), + StructurePreview: () =>
, +})); + +function source(description: string): StudentPreviewSource { + return { + course: { description } as CourseDetails, + content: null, + }; +} + +test("the draft leads, because that is the question being asked", () => { + render( + , + ); + expect(screen.getByTestId("course-preview").textContent).toBe( + "Draft wording", + ); + expect(screen.getByRole("tab", { name: "Draft" }).dataset.state).toBe( + "active", + ); +}); + +test("the published version is one keyboard-reachable control away", () => { + render( + , + ); + // Radix tab triggers act on pointer down, not on a synthetic click alone. + const published = screen.getByRole("tab", { name: "Published" }); + fireEvent.pointerDown(published, { button: 0, ctrlKey: false }); + fireEvent.mouseDown(published, { button: 0, ctrlKey: false }); + expect(screen.getByTestId("course-preview").textContent).toBe( + "Published wording", + ); + expect(screen.getByRole("tablist").getAttribute("aria-label")).toBe( + "Preview content", + ); +}); + +test("an unpublished record shows its draft and says students see nothing", () => { + render( + , + ); + expect(screen.getByTestId("course-preview").textContent).toBe( + "Draft wording", + ); + expect(screen.queryByRole("tab")).toBeNull(); + expect( + screen.getByText( + "This course hasn't been published yet. Students see nothing until you publish.", + ), + ).toBeTruthy(); +}); + +test("a record with no draft shows the publication without a dead control", () => { + render( + , + ); + expect(screen.getByTestId("course-preview").textContent).toBe( + "Published wording", + ); + expect(screen.queryByRole("tab")).toBeNull(); +}); + +test("a record with neither says what would fill it", () => { + render(); + expect(screen.getByText("Nothing to preview yet")).toBeTruthy(); +}); diff --git a/apps/web/tests/catalogue-student-view.test.ts b/apps/web/tests/catalogue-student-view.test.ts new file mode 100644 index 00000000..cbb1f4fb --- /dev/null +++ b/apps/web/tests/catalogue-student-view.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + type CatalogueContent, + emptyCatalogueContent, +} from "../lib/catalogue/content.ts"; +import { courseDetailsFromWrite } from "../lib/coursemap/course-version-view.ts"; + +function draftCourse() { + const content = emptyCatalogueContent({ + kind: "course", + code: "COMP2700", + academicYear: 2027, + title: "Systems and Security", + }) as Extract; + content.course.details.description = "An unpublished description."; + content.course.details.units = 6; + content.course.details.level = 2000; + content.course.details.school = "School of Computing"; + content.course.learningOutcomes = [ + { position: 1, body: "Reason about trust boundaries." }, + ]; + content.requirements = { + rules: [ + { + key: "prerequisite", + hardness: "hard", + sourceText: "COMP1100 or COMP1130", + sourceLocator: null, + reviewState: "verified", + confidence: 1, + position: 0, + }, + ], + groups: [ + { + key: "prerequisite-root", + ruleKey: "prerequisite", + parentKey: null, + label: null, + description: null, + operator: "any_of", + minimumCount: null, + minimumUnits: null, + maximumUnits: null, + sourceText: null, + sourceLocator: null, + position: 0, + }, + ], + conditions: [ + { + key: "prerequisite-comp1100", + ruleKey: "prerequisite", + groupKey: "prerequisite-root", + position: 0, + kind: "course", + itemCode: "COMP1100", + itemKind: "course", + structureKind: null, + requirementMode: "completed", + minimumMark: null, + minimumUnits: null, + maximumUnits: null, + minimumCount: null, + subjectCode: null, + minimumLevel: null, + maximumLevel: null, + minimumYear: null, + minimumGpa: null, + minimumWam: null, + tag: null, + freeText: null, + hardness: "hard", + sourceText: "COMP1100", + sourceLocator: null, + reviewState: "verified", + confidence: 1, + }, + ], + options: [], + references: [ + { + ruleKey: "prerequisite", + code: "COMP1100", + sourceText: "COMP1100", + confidence: 1, + reviewState: "verified", + }, + ], + }; + return content; +} + +test("a draft reads as a course through the published mapping", () => { + const course = courseDetailsFromWrite(draftCourse()); + assert.ok(course); + assert.equal(course.code, "COMP2700"); + assert.equal(course.year, 2027); + assert.equal(course.name, "Systems and Security"); + assert.equal(course.description, "An unpublished description."); + assert.equal(course.units, 6); + assert.equal(course.level, 2000); + assert.equal(course.school, "School of Computing"); + assert.deepEqual(course.learningOutcomes, [ + { position: 1, body: "Reason about trust boundaries." }, + ]); + assert.equal(course.prerequisiteText, "COMP1100 or COMP1130"); + // The codes come from the rule's own reference and from its wording, the + // same way a published read builds them. + assert.deepEqual(course.prerequisiteCodes, ["COMP1100", "COMP1130"]); + assert.ok(course.prerequisiteRule); +}); + +test("a draft says it is a draft and admits what it cannot know", () => { + const course = courseDetailsFromWrite(draftCourse()); + assert.ok(course); + assert.equal(course.publicationStatus, "draft"); + // The reverse lookup runs over published courses only, so a draft must not + // imply that nothing depends on it. + assert.equal(course.unlocksAreKnown, false); + assert.equal(course.snapshotId, undefined); +}); + +test("structure content is not mistaken for a course", () => { + const structure = emptyCatalogueContent({ + kind: "major", + code: "CSEC-MAJ", + academicYear: 2027, + title: "Cyber Security", + }); + assert.equal(courseDetailsFromWrite(structure), null); +}); diff --git a/apps/web/tests/published-cache.test.ts b/apps/web/tests/published-cache.test.ts new file mode 100644 index 00000000..c38841f1 --- /dev/null +++ b/apps/web/tests/published-cache.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { publishedRecordTags } from "../lib/coursemap/published-cache.ts"; + +test("publishing a course drops every read a student could hit", () => { + const tags = publishedRecordTags({ + kind: "course", + academicYear: 2027, + code: "comp2700", + }); + assert.ok(tags.includes("published-course:2027:COMP2700")); + // The directory and the year picker read the same publication state, so a + // published course that never appears in the list is the same defect. + assert.ok(tags.includes("published-courses:2027")); + assert.ok(tags.includes("published-course-page")); + assert.ok(tags.includes("published-course-years")); +}); + +test("publishing a structure drops the structure reads", () => { + const tags = publishedRecordTags({ + kind: "major", + academicYear: 2027, + code: "csec-maj", + }); + assert.deepEqual(tags, [ + "published-structure-detail", + "published-structure:2027:CSEC-MAJ", + ]); +}); diff --git a/apps/web/ui/admin/catalogue/content-editor.tsx b/apps/web/ui/admin/catalogue/content-editor.tsx index e731c8f5..b8d9083f 100644 --- a/apps/web/ui/admin/catalogue/content-editor.tsx +++ b/apps/web/ui/admin/catalogue/content-editor.tsx @@ -269,6 +269,13 @@ export function CatalogueContentEditor({ ); const currentContent = JSON.stringify(write); const dirty = currentContent !== savedContent; + // Publication is the only action here that changes a public page, so it is + // also the only one that has to drop the cached public reads. + const publishedRecord = { + kind: initial.kind, + academicYear: initial.academicYear, + code: initial.code, + }; useEffect(() => { const inactivityTimeout = window.setTimeout( @@ -363,6 +370,7 @@ export function CatalogueContentEditor({ expectedRevision: revision, editingSessionId, path, + record: publishedRecord, }); if (!result.ok) throw new Error(result.error); toast.success(result.message); @@ -377,6 +385,7 @@ export function CatalogueContentEditor({ recordId, editingSessionId, path, + record: publishedRecord, }); if (!result.ok) throw new Error(result.error); toast.success(result.message); diff --git a/apps/web/ui/admin/catalogue/record-page.tsx b/apps/web/ui/admin/catalogue/record-page.tsx index 149e2fa4..bf949f92 100644 --- a/apps/web/ui/admin/catalogue/record-page.tsx +++ b/apps/web/ui/admin/catalogue/record-page.tsx @@ -19,6 +19,7 @@ import { loadVersionCoursePreview, loadVersionWrite, } from "@/lib/coursemap/admin-catalogue-record"; +import { courseDetailsFromWrite } from "@/lib/coursemap/course-version-view"; import { CATALOGUE_KIND_LABELS, type CatalogueKind, @@ -29,9 +30,9 @@ import { AppShell } from "@/ui/shell"; import { CatalogueChangesPanel } from "./changes/changes-panel"; import { ChangelogTimeline } from "./changelog/changelog-timeline"; import { RecordHeader } from "./record-header"; +import { StudentViewPanel } from "./student-view-panel"; import { RecordTabList, RecordTabs, type RecordSection } from "./record-tabs"; import { CatalogueContentEditor } from "./content-editor"; -import { CoursePreview, StructurePreview } from "./version-preview"; function FoundationEmpty({ title, @@ -101,6 +102,16 @@ export async function CatalogueRecordPage({ (!studentContent || draft.contentHash !== contentHashForCatalogueContent(studentContent)), ); + const draftPreview = draft + ? { + course: + kind === "course" ? courseDetailsFromWrite(draft.content) : null, + content: kind === "course" ? null : draft.content, + } + : null; + const publishedPreview = studentContent + ? { course: studentCourse, content: studentCourse ? null : studentContent } + : null; const review = await loadSourceReview( record.recordId, draft?.content ?? null, @@ -151,26 +162,19 @@ export async function CatalogueRecordPage({ initialHasUnpublishedChanges={hasUnpublishedChanges} path={path} /> - ) : draft ? ( - ) : ( )} - {studentCourse ? ( - - ) : studentContent ? ( - - ) : ( - - )} + ; + if (source.content) return ; + return null; +} + +/** + * What students will read, from the draft or from the publication. The draft + * leads because the question an administrator is asking is what publishing + * would do, and both sides render through the same components as the public + * page so a preview cannot quietly drift from it. + */ +export function StudentViewPanel({ + draft, + published, + kindLabel, +}: { + draft: StudentPreviewSource | null; + published: StudentPreviewSource | null; + kindLabel: string; +}) { + const [view, setView] = useState<"draft" | "published">( + draft ? "draft" : "published", + ); + + if (!draft && !published) { + return ( +
+

Nothing to preview yet

+

+ {`Sync from ANU or write the ${kindLabel} content, and this is what students will read.`} +

+
+ ); + } + + if (!draft || !published) { + const only = draft ?? published!; + return ( +
+ {draft ? ( +

+ {`This ${kindLabel} hasn't been published yet. Students see nothing until you publish.`} +

+ ) : null} + +
+ ); + } + + return ( + setView(value as "draft" | "published")} + value={view} + > + + Draft + Published + + + + ); +} diff --git a/apps/web/ui/requirements/structure-catalogue-error.tsx b/apps/web/ui/catalogue/public-record-error.tsx similarity index 59% rename from apps/web/ui/requirements/structure-catalogue-error.tsx rename to apps/web/ui/catalogue/public-record-error.tsx index ee4668a3..f317cb83 100644 --- a/apps/web/ui/requirements/structure-catalogue-error.tsx +++ b/apps/web/ui/catalogue/public-record-error.tsx @@ -1,26 +1,38 @@ "use client"; -import Link from "next/link"; import { Button } from "@coursemap/ui/primitives/button"; +import Link from "next/link"; +import { + CATALOGUE_KIND_LABELS, + type CatalogueKind, +} from "@/lib/coursemap/catalogue-kinds"; import { ErrorState } from "@/ui/common/error-state"; import { AppShell } from "@/ui/shell"; -/** Shown when a published structure cannot be read, on the page and from the boundary. */ -export function StructureCatalogueError({ - retryHref, +/** + * Shown when a published catalogue record cannot be read, both from the page + * itself and from its route boundary. + */ +export function PublicRecordError({ + kind, onRetry, + retryHref, }: { - retryHref?: string; + kind: CatalogueKind; onRetry?: () => void; + retryHref?: string; }) { + const labels = CATALOGUE_KIND_LABELS[kind]; return ( - + {onRetry ? ( - + ) : retryHref ? ( - - - - ); -} +import { CourseDetailClient } from "@/ui/courses/course-detail-client"; +import { StructureDetailClient } from "@/ui/requirements/structure-detail-client"; +import { PublicRecordError } from "./public-record-error"; export async function PublicCatalogueRecordPage({ kind, @@ -72,7 +41,7 @@ export async function PublicCatalogueRecordPage({ loadCurrentUserRequisiteCompletion(), ]); } catch { - return ; + return ; } if (!course) notFound(); return ( @@ -86,7 +55,7 @@ export async function PublicCatalogueRecordPage({ try { structure = await loadPublishedStructure(code, academicYear); } catch { - return ; + return ; } if (!structure || structure.kind !== kind) notFound(); let details; @@ -96,7 +65,7 @@ export async function PublicCatalogueRecordPage({ academicYear, ); } catch { - return ; + return ; } return ( option.kind !== "course", @@ -90,25 +94,46 @@ function StructureOptions({ {conditionInterpretation(condition)}

    - {options.map((option) => ( -
  • - - - - {option.code} - - {option.title ? ( - - {option.title} - - ) : null} + {options.map((option) => { + const name = ( + + + {option.code} - -
  • - ))} + {option.title ? ( + + {option.title} + + ) : null} + + ); + // Every catalogue page is addressed by year. Without one there is no + // page to send the reader to, so the option reads as plain text. + const href = + academicYear !== null && isCatalogueKind(option.kind) + ? publicCatalogueRecordPath( + option.kind, + academicYear, + option.code, + ) + : null; + return ( +
  • + {href ? ( + + {name} + + ) : ( + + {name} + + )} +
  • + ); + })}
); @@ -133,7 +158,10 @@ export function RequirementCondition({ if (condition.conditionKind === "structure_set") { if (!context.showStructureOptions) return null; return condition.options.some((option) => option.kind !== "course") ? ( - + ) : ( ); diff --git a/apps/web/app/structures/[code]/structure-detail-client.tsx b/apps/web/ui/requirements/structure-detail-client.tsx similarity index 97% rename from apps/web/app/structures/[code]/structure-detail-client.tsx rename to apps/web/ui/requirements/structure-detail-client.tsx index 157e12c0..fd269987 100644 --- a/apps/web/app/structures/[code]/structure-detail-client.tsx +++ b/apps/web/ui/requirements/structure-detail-client.tsx @@ -55,7 +55,6 @@ export function StructureDetailClient({ > } - breadcrumbSegmentLabels={{ structures: null }} currentBreadcrumbLabel={structure.name} > 'snapshot', + private.course_version_projection(:unpublished_course) -> 'snapshot', + 'the public page reads exactly the projected version' +); + +select extensions.ok( + pg_get_functiondef('public.admin_catalogue_version_projection(bigint)'::regprocedure) + like '%course_version_projection%', + 'the administrator preview reads the same projection, not a second one' +); + +-- A newer version that nobody published ------------------------------------- + +select pg_temp.create_course_snapshot('TSTP1000'::text, 2027::smallint, 'Newer unpublished work'::text) + as newer_course \gset + +select extensions.is( + public.published_course_detail('TSTP1000', 2027::smallint) -> 'snapshot' ->> 'title', + 'Unpublished course', + 'a newer version does not become public by being newer' +); + +update public.catalogue_records +set published_version_id = null +where id = ( + select record_id from public.catalogue_versions where id = :unpublished_course +); + +select extensions.ok( + public.published_course_detail('TSTP1000', 2027::smallint) is null, + 'unpublishing takes the course back off the public catalogue' +); + +-- Structures behave the same way -------------------------------------------- + +select pg_temp.create_structure_snapshot('major'::text, 'TSTP-MAJ'::text, 2027::smallint, 'Test major'::text) + as unpublished_structure \gset + +select extensions.ok( + public.published_structure_detail('TSTP-MAJ', 2027::smallint) is null, + 'an unpublished structure version is not a public structure' +); + +select pg_temp.publish_snapshot(:unpublished_structure); + +select extensions.ok( + public.published_structure_detail('TSTP-MAJ', 2027::smallint) is not null, + 'publishing a structure makes that version public' +); + +-- Nothing private is readable anonymously ----------------------------------- + +select extensions.ok( + not has_table_privilege('anon', 'public.catalogue_drafts', 'select'), + 'anonymous readers cannot reach drafts' +); + +select extensions.ok( + not has_table_privilege('anon', 'public.catalogue_change_events', 'select'), + 'anonymous readers cannot reach the audit trail' +); + +select extensions.ok( + not has_table_privilege('anon', 'public.catalogue_syncs', 'select'), + 'anonymous readers cannot reach sync diagnostics' +); + +select extensions.ok( + not has_table_privilege('anon', 'public.catalogue_source_documents', 'select'), + 'anonymous readers cannot reach ANU source material' +); + +select * from extensions.finish(); + +rollback;