diff --git a/apps/web/ui/catalogue/public-record-page.tsx b/apps/web/ui/catalogue/public-record-page.tsx
index 70e9818b..288e46a4 100644
--- a/apps/web/ui/catalogue/public-record-page.tsx
+++ b/apps/web/ui/catalogue/public-record-page.tsx
@@ -1,5 +1,3 @@
-import { Button } from "@coursemap/ui/primitives/button";
-import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import type { CatalogueKind } from "@/lib/catalogue/content";
import { requirementCourseCodes } from "@/lib/coursemap/requirement-display";
@@ -9,40 +7,11 @@ import {
loadPublishedCoursesByCodes,
} from "@/lib/coursemap/published-courses";
import { loadPublishedStructure } from "@/lib/coursemap/published-structures";
-import {
- CATALOGUE_KIND_LABELS,
- publicCatalogueRecordPath,
-} from "@/lib/coursemap/catalogue-kinds";
+import { publicCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds";
import { loadCurrentUserRequisiteCompletion } from "@/lib/coursemap/requisite-progress";
-import { CourseDetailClient } from "@/app/courses/[code]/course-detail-client";
-import { StructureDetailClient } from "@/app/structures/[code]/structure-detail-client";
-import { ErrorState } from "@/ui/common/error-state";
-import { AppShell } from "@/ui/shell";
-
-function PublicCatalogueError({
- kind,
- retryHref,
-}: {
- kind: CatalogueKind;
- retryHref: string;
-}) {
- const label = CATALOGUE_KIND_LABELS[kind].singular.toLowerCase();
- return (
-
-
-
- Try again
-
-
- Browse courses
-
-
-
- );
-}
+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;