Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/web/app/courses/[year]/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { PublicRecordError } from "@/ui/catalogue/public-record-error";

export default function Error({ reset }: { reset: () => void }) {
return <PublicRecordError kind="course" onRetry={reset} />;
}
5 changes: 5 additions & 0 deletions apps/web/app/courses/[year]/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading";

export default function Loading() {
return <PublicRecordLoading label="course" />;
}
7 changes: 7 additions & 0 deletions apps/web/app/majors/[year]/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { PublicRecordError } from "@/ui/catalogue/public-record-error";

export default function Error({ reset }: { reset: () => void }) {
return <PublicRecordError kind="major" onRetry={reset} />;
}
5 changes: 5 additions & 0 deletions apps/web/app/majors/[year]/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading";

export default function Loading() {
return <PublicRecordLoading label="major" />;
}
7 changes: 7 additions & 0 deletions apps/web/app/minors/[year]/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { PublicRecordError } from "@/ui/catalogue/public-record-error";

export default function Error({ reset }: { reset: () => void }) {
return <PublicRecordError kind="minor" onRetry={reset} />;
}
5 changes: 5 additions & 0 deletions apps/web/app/minors/[year]/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading";

export default function Loading() {
return <PublicRecordLoading label="minor" />;
}
7 changes: 7 additions & 0 deletions apps/web/app/programmes/[year]/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { PublicRecordError } from "@/ui/catalogue/public-record-error";

export default function Error({ reset }: { reset: () => void }) {
return <PublicRecordError kind="programme" onRetry={reset} />;
}
5 changes: 5 additions & 0 deletions apps/web/app/programmes/[year]/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading";

export default function Loading() {
return <PublicRecordLoading label="programme" />;
}
7 changes: 7 additions & 0 deletions apps/web/app/specialisations/[year]/[code]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"use client";

import { PublicRecordError } from "@/ui/catalogue/public-record-error";

export default function Error({ reset }: { reset: () => void }) {
return <PublicRecordError kind="specialisation" onRetry={reset} />;
}
5 changes: 5 additions & 0 deletions apps/web/app/specialisations/[year]/[code]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PublicRecordLoading } from "@/ui/catalogue/public-record-loading";

export default function Loading() {
return <PublicRecordLoading label="specialisation" />;
}
7 changes: 0 additions & 7 deletions apps/web/app/structures/[code]/error.tsx

This file was deleted.

62 changes: 0 additions & 62 deletions apps/web/app/structures/[code]/page.tsx

This file was deleted.

14 changes: 14 additions & 0 deletions apps/web/lib/coursemap/admin-catalogue-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -59,11 +67,13 @@ export async function publishDraftAction({
expectedRevision,
editingSessionId,
path,
record,
}: {
recordId: number;
expectedRevision: number;
editingSessionId: string;
path: string;
record: PublishedRecord;
}): Promise<DraftActionResult> {
if (!(await canWriteCatalogue()))
return { ok: false, error: "Catalogue write permission is required." };
Expand All @@ -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.");
Expand All @@ -87,10 +98,12 @@ export async function unpublishAction({
recordId,
editingSessionId,
path,
record,
}: {
recordId: number;
editingSessionId: string;
path: string;
record: PublishedRecord;
}): Promise<DraftActionResult> {
if (!(await canWriteCatalogue()))
return { ok: false, error: "Catalogue write permission is required." };
Expand All @@ -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.",
Expand Down
111 changes: 111 additions & 0 deletions apps/web/lib/coursemap/course-version-view.ts
Original file line number Diff line number Diff line change
@@ -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));
}
63 changes: 63 additions & 0 deletions apps/web/lib/coursemap/published-cache.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading