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;