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
11 changes: 10 additions & 1 deletion apps/web/app/admin/courses/[year]/[code]/[[...section]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
export const dynamic = "force-dynamic";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ year: string; code: string; section?: string[] }>;
searchParams: SearchParams;
}) {
return <CatalogueRecordRoute kind="course" {...await params} />;
return (
<CatalogueRecordRoute
searchParams={searchParams}
kind="course"
{...await params}
/>
);
}
11 changes: 10 additions & 1 deletion apps/web/app/admin/majors/[year]/[code]/[[...section]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
export const dynamic = "force-dynamic";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ year: string; code: string; section?: string[] }>;
searchParams: SearchParams;
}) {
return <CatalogueRecordRoute kind="major" {...await params} />;
return (
<CatalogueRecordRoute
searchParams={searchParams}
kind="major"
{...await params}
/>
);
}
11 changes: 10 additions & 1 deletion apps/web/app/admin/minors/[year]/[code]/[[...section]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
export const dynamic = "force-dynamic";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ year: string; code: string; section?: string[] }>;
searchParams: SearchParams;
}) {
return <CatalogueRecordRoute kind="minor" {...await params} />;
return (
<CatalogueRecordRoute
searchParams={searchParams}
kind="minor"
{...await params}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
export const dynamic = "force-dynamic";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ year: string; code: string; section?: string[] }>;
searchParams: SearchParams;
}) {
return <CatalogueRecordRoute kind="programme" {...await params} />;
return (
<CatalogueRecordRoute
searchParams={searchParams}
kind="programme"
{...await params}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import type { SearchParams } from "@/ui/admin/catalogue/catalogue-pages";
import { CatalogueRecordRoute } from "@/ui/admin/catalogue/catalogue-route-pages";
export const dynamic = "force-dynamic";
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ year: string; code: string; section?: string[] }>;
searchParams: SearchParams;
}) {
return <CatalogueRecordRoute kind="specialisation" {...await params} />;
return (
<CatalogueRecordRoute
searchParams={searchParams}
kind="specialisation"
{...await params}
/>
);
}
183 changes: 183 additions & 0 deletions apps/web/lib/catalogue/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { fieldLabel } from "../coursemap/catalogue-kinds.ts";

export type ChangelogEventKind =
| "edit"
| "publish"
| "unpublish"
| "discard"
| "restore"
| "source_draft_created"
| "source_checked"
| "source_changed"
| "sync_failed"
| "source_accepted"
| "source_kept";

export type ChangelogEvent = {
id: number;
eventKind: ChangelogEventKind;
origin: "manual" | "source";
actorId: string | null;
editingSessionId: string | null;
versionId: number | null;
syncChangeId: number | null;
createdAt: string;
fields: Array<{ fieldPath: string; oldValue: unknown; newValue: unknown }>;
/** The review row a source decision answered, when the event has one. */
decision: { fieldPath: string; decision: "use_source" | "keep_local" } | null;
};

export type ChangelogFieldChange = {
fieldPath: string;
label: string;
oldValue: unknown;
newValue: unknown;
};

export type ChangelogEntry = {
id: string;
kind: ChangelogEventKind;
at: string;
startedAt: string;
actorId: string | null;
origin: "manual" | "source";
eventIds: number[];
versionId: number | null;
fields: ChangelogFieldChange[];
usedFromSource: string[];
keptLocal: string[];
};

/** Decisions taken in one sitting read as one review, not as five rows. */
const DECISION_WINDOW_MS = 30 * 60 * 1000;

function sameEditingSession(left: ChangelogEvent, right: ChangelogEvent) {
return (
left.eventKind === "edit" &&
right.eventKind === "edit" &&
left.editingSessionId !== null &&
left.editingSessionId === right.editingSessionId &&
left.actorId === right.actorId
);
}

function sameDecisionSitting(left: ChangelogEvent, right: ChangelogEvent) {
const decisions = new Set(["source_accepted", "source_kept"]);
return (
decisions.has(left.eventKind) &&
decisions.has(right.eventKind) &&
left.actorId === right.actorId &&
Math.abs(Date.parse(left.createdAt) - Date.parse(right.createdAt)) <=
DECISION_WINDOW_MS
);
}

function sameQuietCheck(left: ChangelogEvent, right: ChangelogEvent) {
return (
left.eventKind === "source_checked" && right.eventKind === "source_checked"
);
}

function belongsToGroup(group: ChangelogEvent[], event: ChangelogEvent) {
const last = group[group.length - 1]!;
return (
sameEditingSession(last, event) ||
sameDecisionSitting(last, event) ||
sameQuietCheck(last, event)
);
}

/**
* Collapses one group of raw events into the field changes a reader cares
* about: the value before the session started against the value it ended on,
* with paths that came back to where they started dropped entirely.
*/
function mergedFields(events: ChangelogEvent[]): ChangelogFieldChange[] {
const oldest = new Map<string, unknown>();
const newest = new Map<string, unknown>();
const order: string[] = [];
// Events arrive newest first, so the last one seen for a path is the oldest.
for (const event of events) {
for (const change of event.fields) {
if (!newest.has(change.fieldPath)) {
newest.set(change.fieldPath, change.newValue);
order.push(change.fieldPath);
}
oldest.set(change.fieldPath, change.oldValue);
}
}
return order.flatMap((fieldPath) => {
const oldValue = oldest.get(fieldPath) ?? null;
const newValue = newest.get(fieldPath) ?? null;
if (JSON.stringify(oldValue ?? null) === JSON.stringify(newValue ?? null))
return [];
return [{ fieldPath, label: fieldLabel(fieldPath), oldValue, newValue }];
});
}

/**
* One human timeline from raw audit events, newest first. Grouping is
* presentation only: every event keeps its own row in the database, and an
* entry names the events it covers so the raw history stays reachable.
*/
export function groupChangelogEvents(
events: readonly ChangelogEvent[],
): ChangelogEntry[] {
const groups: ChangelogEvent[][] = [];
for (const event of events) {
const current = groups[groups.length - 1];
if (current && belongsToGroup(current, event)) current.push(event);
else groups.push([event]);
}

return groups.map((group) => {
const newest = group[0]!;
const oldest = group[group.length - 1]!;
const decisions = group.flatMap((event) =>
event.decision ? [event.decision] : [],
);
return {
id: `event-${newest.id}`,
kind: newest.eventKind,
at: newest.createdAt,
startedAt: oldest.createdAt,
actorId: newest.actorId,
origin: newest.origin,
eventIds: group.map((event) => event.id),
versionId: newest.versionId,
fields: mergedFields(group),
usedFromSource: decisions
.filter((entry) => entry.decision === "use_source")
.map((entry) => fieldLabel(entry.fieldPath)),
keptLocal: decisions
.filter((entry) => entry.decision === "keep_local")
.map((entry) => fieldLabel(entry.fieldPath)),
} satisfies ChangelogEntry;
});
}

export const CHANGELOG_PAGE_SIZE = 40;

export type ChangelogEntryView = ChangelogEntry & {
actorName: string | null;
versionOrdinal: number | null;
/** Review totals for the sync that produced a source change entry. */
sourceChanges: { total: number; conflicts: number } | null;
};

export type CatalogueChangelog = {
entries: ChangelogEntryView[];
shown: number;
hasMore: boolean;
};

/** "5 autosaves over 4 minutes", or null when there is nothing to summarise. */
export function editingSessionSummary(entry: ChangelogEntry) {
if (entry.kind !== "edit" || entry.eventIds.length < 2) return null;
const minutes = Math.round(
(Date.parse(entry.at) - Date.parse(entry.startedAt)) / 60_000,
);
const saves = `${entry.eventIds.length} autosaves`;
if (minutes < 1) return `${saves} in under a minute`;
return `${saves} over ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
21 changes: 20 additions & 1 deletion apps/web/lib/catalogue/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ export async function restoreCatalogueVersion({
select * from public.catalogue_drafts where record_id = ${recordId} for update
`;
const existing = existingRow ? draftFromRow(existingRow) : null;
let replacedVersionId: number | null = null;
if (existing) {
if (!replaceExistingDraft)
throw new CatalogueDraftError(
Expand All @@ -613,6 +614,24 @@ export async function restoreCatalogueVersion({
);
if (expectedRevision === null || existing.revision !== expectedRevision)
throw new CatalogueDraftConflictError(existing.revision);
// Restoring must not destroy work. The draft it replaces becomes a
// version of its own, so the changelog can offer it back.
if (await draftIsMeaningful(tx, record, existing)) {
replacedVersionId = await materialiseDraftVersion(tx, {
record,
draft: existing,
userId,
});
await tx`
insert into public.catalogue_change_events (
record_id, draft_revision, event_kind, origin, actor_id,
editing_session_id, version_id
) values (
${recordId}, ${existing.revision}, 'discard', 'manual',
${userId}::uuid, ${editingSessionId}::uuid, ${replacedVersionId}
)
`;
}
}
const revision = existing ? existing.revision + 1 : 0;
const contentHash = contentHashForCatalogueContent(content);
Expand Down Expand Up @@ -648,7 +667,7 @@ export async function restoreCatalogueVersion({
${editingSessionId}::uuid, ${versionId}
)
`;
return { revision, content: restored };
return { revision, content: restored, replacedVersionId };
});
return sql ? work(sql) : withSyncDatabaseClient(work);
}
10 changes: 6 additions & 4 deletions apps/web/lib/catalogue/source-review-decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,11 @@ export async function resolveSourceChange({
`;
const [event] = await tx`
insert into public.catalogue_change_events (
record_id, draft_revision, event_kind, origin, actor_id, version_id
record_id, draft_revision, event_kind, origin, actor_id, version_id,
sync_change_id
) values (
${recordId}, ${revision}, 'source_accepted', 'source',
${userId}::uuid, ${sourceVersionId}
${userId}::uuid, ${sourceVersionId}, ${changeId}
) returning id
`;
for (const [position, change] of fieldChanges.entries()) {
Expand All @@ -186,10 +187,11 @@ export async function resolveSourceChange({
} else {
await tx`
insert into public.catalogue_change_events (
record_id, draft_revision, event_kind, origin, actor_id, version_id
record_id, draft_revision, event_kind, origin, actor_id, version_id,
sync_change_id
) values (
${recordId}, ${revision}, 'source_kept', 'source', ${userId}::uuid,
${sourceVersionId}
${sourceVersionId}, ${changeId}
)
`;
}
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/coursemap/admin-catalogue-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ export async function restoreCatalogueVersionAction({
return {
ok: true,
revision: result.revision,
message: "Version restored as a draft.",
message: result.replacedVersionId
? "Version restored as a draft. Your previous draft is in the Changelog."
: "Version restored as a draft.",
};
} catch (error) {
return draftFailure(error, "The version could not be restored.");
Expand Down
Loading
Loading