- );
-}
diff --git a/apps/web/ui/courses/requisite-summary.tsx b/apps/web/ui/courses/requisite-summary.tsx
index 80ec9268..417c83c1 100644
--- a/apps/web/ui/courses/requisite-summary.tsx
+++ b/apps/web/ui/courses/requisite-summary.tsx
@@ -1,12 +1,18 @@
"use client";
import { badgeVariantForTone } from "@/lib/ui";
import { Badge } from "@coursemap/ui/components/badge";
-import { CheckCircle2, Circle } from "lucide-react";
+import { CheckCircle2, Circle, CircleAlert } from "lucide-react";
import {
type RequisiteCondition,
type RequisiteExpression,
type RequisiteProgress,
} from "@/lib/coursemap/requisite-summary";
+import type { CourseRuleExpression } from "@/lib/coursemap/course-types";
+import { requisiteConditionNode } from "@/lib/coursemap/requisite-tree";
+import {
+ conditionSummary,
+ conditionTone,
+} from "@/ui/requirements/requirement-presentation";
import { CourseReferenceText } from "@/ui/courses/course-reference";
export function RequisiteConditionText({
@@ -220,3 +226,136 @@ export function RequisiteProgressSummary({
);
}
+
+/**
+ * A requisite rule read through the shared requirement vocabulary. The narrow
+ * summary above covers the handful of kinds it was written for and returns
+ * nothing for the rest, which used to leave the reader with the ANU prose
+ * alone. Course codes stay linked, so this loses nothing the prose carried.
+ */
+/**
+ * What a group asks for, in the reader's terms. "Complete all of the
+ * following" over "Complete one of the following" read alike at a glance, and
+ * the difference between them is the whole rule.
+ */
+function groupTitle(
+ expression: Extract,
+ childCount: number,
+) {
+ if (expression.operator === "all_of") {
+ return childCount === 2
+ ? "You need both of these"
+ : "You need all of these";
+ }
+ if (expression.operator === "any_of") return "You need one of these";
+ return `You need at least ${expression.minimumCount ?? 1} of these`;
+}
+
+export function RequisiteRuleSummary({
+ academicYear,
+ expression,
+ availableCourseCodes,
+ depth = 0,
+}: {
+ academicYear: number;
+ expression: CourseRuleExpression;
+ availableCourseCodes: ReadonlySet;
+ /** Only the outermost group is boxed; nesting a box in a box hid the logic. */
+ depth?: number;
+}) {
+ if (expression.kind === "group") {
+ // A group of one is only its child, so it gets no heading of its own.
+ if (expression.conditions.length === 1 && expression.conditions[0]) {
+ return (
+
+ );
+ }
+ const alternative = expression.operator !== "all_of";
+ const list = (
+ <>
+
+ );
+}
diff --git a/apps/web/ui/prereq-graph.tsx b/apps/web/ui/prereq-graph.tsx
index 1b5f83a1..4c52b1f2 100644
--- a/apps/web/ui/prereq-graph.tsx
+++ b/apps/web/ui/prereq-graph.tsx
@@ -1,297 +1,503 @@
"use client";
import Link from "next/link";
-import { Check, LockKeyhole } from "lucide-react";
-import { useMemo } from "react";
+import {
+ CalendarDays,
+ Check,
+ CircleAlert,
+ CircleDashed,
+ GaugeCircle,
+ LockKeyhole,
+} from "lucide-react";
+import { Fragment, useId, useMemo } from "react";
+import { Badge } from "@coursemap/ui/components/badge";
import { Hint } from "@/ui/common/hint";
import { cn } from "@/lib/cn";
-import type { CoursePrerequisiteEdge } from "@/lib/coursemap/course-types";
+import type {
+ CoursePrerequisiteEdge,
+ CourseRuleExpression,
+} from "@/lib/coursemap/course-types";
+import {
+ buildRequisiteGraph,
+ requisiteConditionNode,
+ type RequisiteGraphNode,
+} from "@/lib/coursemap/requisite-tree";
+import { conditionSummary } from "@/ui/requirements/requirement-presentation";
-const NODE_H = 46;
-const GAP = 14;
-const STEP = NODE_H + GAP;
+const COLUMN_WIDTH = 168;
+/**
+ * A junction is a short pill ("Choose one", "All of these"), so a column that
+ * holds nothing else is narrower. At a uniform width the junction that makes
+ * the AND explicit added a full column and pushed the graph past its card.
+ */
+const JUNCTION_WIDTH = 116;
+const COLUMN_GAP = 36;
+const ROW_GAP = 14;
+const CHOICE_HEIGHT = 34;
+const REQUIREMENT_HEIGHT = 68;
+const EMPTY_HEIGHT = 46;
+const ARROW_INSET = 3;
+
+type CourseStatus = "completed" | "enrolled" | "planned";
-type Layout = {
- columns: { label: string; codes: string[] }[];
- edges: CoursePrerequisiteEdge[];
- position: Map;
- rows: number;
+/** A node, or a column's empty state, with somewhere to sit on the canvas. */
+type Placed = {
+ column: number;
+ height: number;
+ id: string;
+ node: RequisiteGraphNode | null;
+ top: number;
};
-/**
- * Display the complete upstream chain and the direct courses this course
- * unlocks. The graph is intentionally descriptive: an imported reference is
- * not treated as a verified enrolment rule until its source has been reviewed.
- */
-function buildLayout(
- code: string,
- prerequisiteEdges: readonly CoursePrerequisiteEdge[],
-): Layout {
- const incoming = new Map();
- for (const edge of prerequisiteEdges) {
- const existing = incoming.get(edge.to) ?? [];
- existing.push(edge);
- incoming.set(edge.to, existing);
- }
+function courseHeight(showStudentState: boolean) {
+ return showStudentState ? 64 : 46;
+}
- const level = new Map([[code, 0]]);
- const visitUpstream = (
- courseCode: string,
- depth: number,
- path: Set,
- ) => {
- for (const edge of incoming.get(courseCode) ?? []) {
- if (path.has(edge.from)) continue;
- const nextDepth = depth - 1;
- const existing = level.get(edge.from);
- if (existing === undefined || nextDepth < existing) {
- level.set(edge.from, nextDepth);
- }
- visitUpstream(edge.from, nextDepth, new Set([...path, edge.from]));
- }
- };
- visitUpstream(code, 0, new Set([code]));
+function nodeHeight(node: RequisiteGraphNode, showStudentState: boolean) {
+ if (node.kind === "choice") return CHOICE_HEIGHT;
+ if (node.kind === "requirement") return REQUIREMENT_HEIGHT;
+ if (node.kind === "current") return courseHeight(false);
+ return courseHeight(showStudentState);
+}
- for (const edge of prerequisiteEdges) {
- if (edge.from === code && edge.to !== code) {
- level.set(edge.to, 1);
- }
- }
+const STATUS_LABEL: Record = {
+ completed: "Completed",
+ enrolled: "Enrolled",
+ planned: "Planned",
+};
- const minLevel = Math.min(-1, ...level.values());
- const maxLevel = Math.max(1, ...level.values());
- const columns = Array.from(
- { length: maxLevel - minLevel + 1 },
- (_, index) => {
- const columnLevel = index + minLevel;
- return {
- label:
- columnLevel === 0
- ? "This course"
- : columnLevel === 1
- ? "Unlocks"
- : columnLevel === -1
- ? "Requires"
- : "Then requires",
- codes: [...level.entries()]
- .filter(([, nodeLevel]) => nodeLevel === columnLevel)
- .map(([courseCode]) => courseCode)
- .sort(),
- };
- },
- );
- const position = new Map();
- columns.forEach((column, col) =>
- column.codes.forEach((item, row) => position.set(item, { col, row })),
- );
- const edges = prerequisiteEdges.filter(
- (edge) => position.has(edge.from) && position.has(edge.to),
+/**
+ * The same words and icons the requirement kit puts on a course row, so a
+ * course means the same thing in the graph and in the card underneath it.
+ */
+function CourseStatusBadge({ status }: { status: CourseStatus | null }) {
+ return (
+
+ {status === "completed" ? (
+
+ ) : status ? (
+
+ ) : (
+
+ )}
+ {status ? STATUS_LABEL[status] : "Not planned"}
+
);
+}
- return {
- columns,
- edges,
- position,
- rows: Math.max(1, ...columns.map((column) => column.codes.length)),
- };
+function choiceLabel(node: Extract) {
+ if (node.operator === "all_of") return "All of these";
+ if (node.operator === "any_of") return "Choose one";
+ return `Choose at least ${node.minimumCount ?? 1}`;
}
+/**
+ * The prerequisite rule as a left-to-right dependency graph, with a node for
+ * every condition the rule states. Alternatives get a group node so an OR
+ * cannot be mistaken for a list of separate requirements, and a unit rule is
+ * a node like any other rather than being dropped for naming no single course.
+ *
+ * Drawn with layout and SVG rather than the React Flow canvas the reviewer's
+ * editor uses: every node here is a real link or a real piece of text in
+ * reading order, which a student on a phone or a screen reader needs and a
+ * pannable canvas takes away.
+ */
export function PrereqGraph({
academicYear,
+ availableCourseCodes,
code,
- prerequisiteEdges,
- completedCodes,
+ expression,
hasPrerequisiteWording,
- plannedCodes,
+ prerequisiteEdges,
+ showStudentState,
+ statusByCode,
+ unlocksAreKnown,
}: {
academicYear: number;
+ availableCourseCodes: ReadonlySet;
code: string;
- prerequisiteEdges: readonly CoursePrerequisiteEdge[];
- completedCodes: ReadonlySet;
+ expression: CourseRuleExpression | null;
hasPrerequisiteWording: boolean;
- plannedCodes: ReadonlySet;
+ prerequisiteEdges: readonly CoursePrerequisiteEdge[];
+ showStudentState: boolean;
+ statusByCode: ReadonlyMap;
+ unlocksAreKnown: boolean;
}) {
- const layout = useMemo(
- () => buildLayout(code, prerequisiteEdges),
- [code, prerequisiteEdges],
+ const markerId = useId();
+ const graph = useMemo(
+ () =>
+ buildRequisiteGraph({
+ availableCourseCodes,
+ code,
+ expression,
+ prerequisiteEdges,
+ }),
+ [availableCourseCodes, code, expression, prerequisiteEdges],
);
- const { columns, edges, position, rows } = layout;
- const height = rows * STEP - GAP;
- const columnCount = columns.length;
- const availability = new Map([[code, true]]);
- for (const edge of prerequisiteEdges) {
- availability.set(
- edge.from,
- availability.get(edge.from) === true || edge.fromIsAvailable,
- );
- availability.set(
- edge.to,
- availability.get(edge.to) === true || edge.toIsAvailable,
+
+ // With nothing on either side there is no chain to draw. Three empty boxes
+ // with no edges between them read as a diagram that failed to render, and
+ // drawing arrows to placeholders would invent relationships, so say it.
+ if (graph.nodes.every((node) => node.kind === "current")) {
+ return (
+
+ {unlocksAreKnown
+ ? `${code} has no prerequisites, and no published course lists it as one.`
+ : `${code} has no prerequisites. Which courses it leads to is not known until it is published.`}
+
+
+
+
+ {/* One line that leads with the figure: a category heading over a
+ detail line repeated itself and left "24 units" in the faintest
+ text on the node. */}
+
+ {summary}
+
+
+ );
+ }
+
+ if (node.kind === "current") {
+ return (
+
+ {node.code}
+
+ );
+ }
+
+ const status = statusByCode.get(node.code) ?? null;
+ const body = (
+ <>
+ {node.code}
+ {node.kind === "course" &&
+ node.condition?.kind === "course" &&
+ node.condition.requirementMode === "completed_or_concurrent" ? (
+
+ Completed or taken at the same time
+
+ ) : null}
+ {showStudentState && node.isAvailable ? (
+
+ ) : null}
+ >
+ );
+ const className = cn(
+ "absolute flex flex-col items-center justify-center gap-1 rounded-lg px-2 text-center transition-colors motion-reduce:transition-none",
+ !node.isAvailable
+ ? "border border-border bg-muted/40 text-muted-foreground"
+ : status === "completed"
+ ? "border border-success/30 bg-success/5 hover:bg-success/10"
+ : status
+ ? "border border-primary/30 bg-primary/5 hover:bg-primary/10"
+ : "border border-border bg-card hover:border-foreground/20 hover:bg-muted/40",
+ );
+
+ if (!node.isAvailable) {
+ return (
+
+
+
+
+
+ {node.code}
+
+
+ Not available
+
+
+ );
+ }
+
+ return (
+
+ {body}
+
+ );
+}
diff --git a/apps/web/ui/requirements/requirement-condition.tsx b/apps/web/ui/requirements/requirement-condition.tsx
index 3de5c51b..8ce867e7 100644
--- a/apps/web/ui/requirements/requirement-condition.tsx
+++ b/apps/web/ui/requirements/requirement-condition.tsx
@@ -1,33 +1,143 @@
"use client";
import { useId, useState } from "react";
-import { ChevronDown, ListChecks, Layers } from "lucide-react";
-import type { PlanRequirementCondition } from "@/lib/coursemap/plan-catalogue";
+import Link from "next/link";
+import {
+ ChevronDown,
+ CircleAlert,
+ GaugeCircle,
+ Info,
+ Layers,
+ ListChecks,
+} from "lucide-react";
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "@coursemap/ui/components/alert";
+import { Badge } from "@coursemap/ui/components/badge";
import { requirementNodeKey } from "@/lib/coursemap/requirement-progress";
import { requirementCourseHeading } from "@/lib/coursemap/requirement-display";
import {
+ conditionHeading,
conditionInterpretation,
+ conditionTone,
unitsDescription,
} from "@/ui/requirements/requirement-presentation";
-import type { TreeContext } from "@/ui/requirements/requirement-presentation";
+import type {
+ RequirementTreeCondition,
+ TreeContext,
+} from "@/ui/requirements/requirement-presentation";
import { RequirementCourseOptions } from "./requirement-course-options";
import { UnitsBar } from "@/ui/requirements/units-bar";
+/** A rule with no course list of its own: units, levels, tags and electives. */
+function StatedCondition({
+ condition,
+}: {
+ condition: RequirementTreeCondition;
+}) {
+ const tone = conditionTone(condition);
+ const interpretation =
+ conditionInterpretation(condition) || condition.freeText;
+ if (tone === "warning" || tone === "note") {
+ return (
+
+ {tone === "warning" ? (
+
+ ) : (
+
+ )}
+ {conditionHeading(condition)}
+ {interpretation}
+
+ );
+ }
+ return (
+
+
+
+
+
+
+
+ {conditionHeading(condition)}
+
+
+ {interpretation}
+
+
+ {tone === "limit" ? Limit : null}
+
+
+ );
+}
+
+/** Academic structures a rule offers, for readers with no chooser of their own. */
+function StructureOptions({
+ condition,
+}: {
+ condition: RequirementTreeCondition;
+}) {
+ const options = condition.options.filter(
+ (option) => option.kind !== "course",
+ );
+ return (
+
+
+ );
+}
+
+const CODE_LINE = /^[A-Z]{4}[0-9]{4}[A-Z]?$|^[A-Z0-9][A-Z0-9-]{1,31}$/u;
+
+/**
+ * ANU section bodies arrive as one line per scraped element, so rendering them
+ * as pre-wrapped text produced a wall with no rhythm: a course code, its
+ * title and its unit value read as three unrelated sentences. Each line is
+ * given its own row, and a bare code is set in the monospace face so a study
+ * plan scans as a list of courses rather than prose.
+ */
+function SectionLines({ markdown }: { markdown: string }) {
+ const lines = markdown
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (lines.length === 0)
+ return (
+
+ The ANU page left this section empty.
+
+ );
+ if (lines.length === 1)
+ return (
+
{lines[0]}
+ );
+ return (
+
+ {lines.map((line, index) => (
+
+ {line}
+
+ ))}
+
+ );
+}
+
+function sectionAnchor(sectionKey: string) {
+ return `section-${sectionKey}`;
+}
+
+/**
+ * The reader's body of a structure page. The student route and the import
+ * preview both render this, so a draft preview cannot drift away from what a
+ * reader will see once it is published.
+ */
+export function StructureDetailView({
+ structure,
+ treeContext,
+}: {
+ structure: StructureDetails;
+ treeContext: TreeContext;
+}) {
+ const kindLabel = CATALOGUE_KIND_LABELS[structure.kind].singular;
+ const facts = [
+ ["Units", structure.units === null ? null : `${structure.units} units`],
+ [
+ "Duration",
+ structure.durationYears === null
+ ? null
+ : `${structure.durationYears} years`,
+ ],
+ ["Academic career", structure.academicCareer],
+ ["College", structure.college],
+ ["Delivery", structure.modeOfDelivery],
+ [
+ "Selection rank",
+ structure.selectionRank === null ? null : `${structure.selectionRank}`,
+ ],
+ ["ATAR", structure.atar === null ? null : `${structure.atar}`],
+ ["Study as", structure.studyAs],
+ ].filter((entry): entry is [string, string] => Boolean(entry[1]));
+
+ const informationSections = structure.sections.filter(
+ (section) => !SECTIONS_RENDERED_ELSEWHERE.has(section.sectionKey),
+ );
+ // A structure is listed once as an option and again as merely relevant, so
+ // only the options count, and each code appears once.
+ const optionsByKind = (kind: string) => [
+ ...new Map(
+ structure.relationships
+ .filter(
+ (relationship) =>
+ relationship.relationshipKind === "option" &&
+ relationship.targetKind === kind,
+ )
+ .map((relationship) => [relationship.targetCode, relationship]),
+ ).values(),
+ ];
+
+ return (
+
+
+
+
+ {structure.requirements ? (
+
+ ) : (
+
+
+
+
+
+ No requirements imported yet
+
+ The ANU page for this {kindLabel.toLowerCase()} has not been
+ read into a requirement tree.
+
+
+
+ )}
+
+
+
+ {informationSections.length ? (
+ <>
+ ({
+ id: sectionAnchor(section.sectionKey),
+ label: section.heading,
+ }))}
+ />
+ {informationSections.map((section) => {
+ const optionKind = LINKED_LIST_SECTIONS[section.sectionKey];
+ const options = optionKind ? optionsByKind(optionKind) : [];
+ return (
+
+
+
+
{section.heading}
+
+
+
+ {options.length ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+ >
+ ) : (
+
+
+
+
+
+ Nothing further published
+
+ The ANU page carried no sections beyond the requirements.
+
+
+
+ )}
+
+
+ );
+}
diff --git a/supabase/migrations/20260920200000_published_structure_detail.sql b/supabase/migrations/20260920200000_published_structure_detail.sql
new file mode 100644
index 00000000..33bb143b
--- /dev/null
+++ b/supabase/migrations/20260920200000_published_structure_detail.sql
@@ -0,0 +1,190 @@
+begin;
+
+-- Published reads existed for courses only, so a programme, major, minor or
+-- specialisation had no way of reaching a reader. This adds the structure
+-- equivalent of `published_course_detail`, with the same security posture:
+-- the projection stays private, and the public entry point resolves through
+-- `catalogue_item_years.published_snapshot_id` so no draft can be reached.
+
+create or replace function private.structure_snapshot_projection(p_snapshot_id bigint)
+returns jsonb
+language sql
+stable
+set search_path = ''
+as $function$
+ with selected_snapshot as (
+ select
+ snapshots.id,
+ snapshots.origin,
+ details.*,
+ items.code as structure_code,
+ academic_years.year as academic_year
+ from public.catalogue_snapshots as snapshots
+ join public.structure_snapshot_details as details on details.snapshot_id = snapshots.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.academic_years on academic_years.id = snapshots.academic_year_id
+ where snapshots.id = p_snapshot_id
+ )
+ select jsonb_build_object(
+ 'structureCode', snapshot.structure_code,
+ 'structureKind', snapshot.kind,
+ 'academicYear', snapshot.academic_year,
+ 'origin', snapshot.origin,
+ 'snapshot', jsonb_build_object(
+ 'name', snapshot.name,
+ 'acronym', snapshot.acronym,
+ 'shortName', snapshot.short_name,
+ 'introduction', snapshot.introduction,
+ 'description', snapshot.description,
+ 'units', snapshot.units,
+ 'durationYears', snapshot.duration_years,
+ 'academicCareer', snapshot.academic_career,
+ 'college', snapshot.college,
+ 'modeOfDelivery', snapshot.mode_of_delivery,
+ 'selectionRank', snapshot.selection_rank,
+ 'atar', snapshot.atar,
+ 'canCombine', snapshot.can_combine,
+ 'canCombineVertical', snapshot.can_combine_vertical,
+ 'studyAs', snapshot.study_as,
+ 'contactText', snapshot.contact_text
+ ),
+ 'sections', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'position', sections.position,
+ 'sectionKey', sections.section_key,
+ 'heading', sections.heading,
+ 'markdown', sections.markdown
+ ) order by sections.position)
+ from public.academic_structure_snapshot_sections as sections
+ where sections.snapshot_id = p_snapshot_id
+ ), '[]'::jsonb),
+ 'learningOutcomes', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'position', outcomes.position,
+ 'outcomeText', outcomes.outcome_text
+ ) order by outcomes.position)
+ from public.academic_structure_learning_outcomes as outcomes
+ where outcomes.snapshot_id = p_snapshot_id
+ ), '[]'::jsonb),
+ 'fees', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'position', fees.position,
+ 'feeYear', fees.fee_year,
+ 'audience', fees.audience,
+ 'feeType', fees.fee_type,
+ 'amount', fees.amount,
+ 'currency', fees.currency,
+ 'basis', fees.basis,
+ 'sourceLabel', fees.source_label,
+ 'sourceText', fees.source_text
+ ) order by fees.position)
+ from public.academic_structure_fees as fees
+ where fees.snapshot_id = p_snapshot_id
+ ), '[]'::jsonb),
+ 'relationships', coalesce((
+ select jsonb_agg(jsonb_build_object(
+ 'position', relationships.position,
+ 'relationshipKind', relationships.relationship_kind,
+ 'targetKind', relationships.target_kind,
+ 'targetCode', relationships.target_code,
+ 'targetTitle', relationships.target_title
+ ) order by relationships.position)
+ from public.academic_structure_snapshot_relationships as relationships
+ where relationships.snapshot_id = p_snapshot_id
+ ), '[]'::jsonb),
+ 'requirements', private.requirement_projection(p_snapshot_id),
+ -- Structure options store a code and nothing else, so a reader would see
+ -- "COMS-MAJ" with no name. Resolve each one through its own published
+ -- snapshot for the same year.
+ 'requirementOptionTitles', coalesce((
+ select jsonb_object_agg(resolved.code, resolved.name)
+ from (
+ select distinct on (option_items.code)
+ option_items.code,
+ option_details.name
+ from public.requirement_condition_options as options
+ join public.catalogue_items as option_items on option_items.id = options.item_id
+ join public.catalogue_item_years as option_years
+ on option_years.item_id = option_items.id
+ and option_years.academic_year_id = (
+ select snapshots.academic_year_id
+ from public.catalogue_snapshots as snapshots
+ where snapshots.id = p_snapshot_id
+ )
+ and option_years.archived_at is null
+ join public.structure_snapshot_details as option_details
+ on option_details.snapshot_id = option_years.published_snapshot_id
+ where options.snapshot_id = p_snapshot_id
+ and options.kind <> 'course'
+ ) as resolved
+ ), '{}'::jsonb)
+ )
+ from selected_snapshot as snapshot;
+$function$;
+
+revoke all on function private.structure_snapshot_projection(bigint)
+from public, anon, authenticated;
+
+create or replace function public.published_structure_detail(
+ p_structure_code text,
+ p_academic_year smallint
+)
+returns jsonb
+language sql
+stable
+security definer
+set search_path = ''
+as $function$
+ -- Security definer so the private projection is callable; the CTE selects
+ -- only the published snapshot, so no draft content can be reached.
+ with selected as (
+ select item_years.published_snapshot_id as snapshot_id
+ from public.catalogue_items as items
+ join public.catalogue_item_years as item_years
+ on item_years.item_id = items.id
+ and item_years.archived_at is null
+ join public.academic_years
+ on academic_years.id = item_years.academic_year_id
+ and academic_years.year = p_academic_year
+ where items.kind in ('programme', 'major', 'minor', 'specialisation')
+ and items.code = upper(btrim(p_structure_code))
+ and item_years.published_snapshot_id is not null
+ limit 1
+ )
+ select private.structure_snapshot_projection(selected.snapshot_id)
+ || jsonb_build_object('snapshotId', selected.snapshot_id)
+ from selected;
+$function$;
+
+revoke all on function public.published_structure_detail(text, smallint) from public;
+grant execute on function public.published_structure_detail(text, smallint)
+to anon, authenticated;
+
+-- The published years a structure can be read in, so a page can offer another
+-- year rather than reporting the code as missing.
+create or replace function public.published_structure_years(p_structure_code text)
+returns table (academic_year smallint, structure_kind text)
+language sql
+stable
+security definer
+set search_path = ''
+as $function$
+ select
+ academic_years.year as academic_year,
+ items.kind as structure_kind
+ from public.catalogue_items as items
+ join public.catalogue_item_years as item_years
+ on item_years.item_id = items.id
+ and item_years.archived_at is null
+ and item_years.published_snapshot_id is not null
+ join public.academic_years on academic_years.id = item_years.academic_year_id
+ where items.kind in ('programme', 'major', 'minor', 'specialisation')
+ and items.code = upper(btrim(p_structure_code))
+ order by academic_years.year desc;
+$function$;
+
+revoke all on function public.published_structure_years(text) from public;
+grant execute on function public.published_structure_years(text) to anon, authenticated;
+
+commit;