Skip to content
Open
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
71 changes: 71 additions & 0 deletions src/components/Admin/ScheduleImport/DiffReviewStep.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { DiffReviewStep } from "./DiffReviewStep";
import type { DiffResult } from "@/services/scheduleImport/types";

const emptyDiff: DiffResult = {
watermark: "0:none",
summary: {
newArtists: 0,
newStages: 0,
setsMatched: 0,
setsToCreate: 0,
setsOrphaned: 0,
},
newArtistNames: [],
cleanOperations: {
artistsToCreate: [],
stagesToCreate: [],
setsToCreate: [],
setsToUpdate: [],
},
conflicts: { stageNameMismatches: [], orphanedSets: [] },
};

function renderStep(commitError: string | null) {
render(
<DiffReviewStep
diff={emptyDiff}
timezone="UTC"
dbStages={[]}
stageMismatchResolutions={{}}
orphanResolutions={{}}
onStageMismatchChange={vi.fn()}
onOrphanChange={vi.fn()}
onCommit={vi.fn()}
onReset={vi.fn()}
committing={false}
commitError={commitError}
canCommit
currentRevealLevel="draft"
/>,
);
}

describe("DiffReviewStep", () => {
it("shows a dedicated message and disables the primary button when the edition changed", () => {
renderStep(
"edition_changed_since_analyse: The schedule changed since this review was generated.",
);

expect(
screen.getByText("The schedule changed since this review"),
).toBeVisible();
expect(screen.queryByText("Retry")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Commit to database" }),
).toBeDisabled();
});

it("shows the generic failure message and an enabled Retry for other commit errors", () => {
renderStep("Stage Mainstage not found in edition edition-1");

expect(
screen.getByText("Import failed — no changes were saved."),
).toBeVisible();
expect(
screen.getByText("Stage Mainstage not found in edition edition-1"),
).toBeVisible();
expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
});
});
22 changes: 18 additions & 4 deletions src/components/Admin/ScheduleImport/DiffReviewStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type DiffResult,
type StageMismatchResolution,
type OrphanResolution,
isEditionChangedError,
} from "@/services/scheduleImport/types";
import type { RevealLevel } from "@/lib/scheduleReveal";
import { DiffSummaryBanner } from "./DiffSummaryBanner";
Expand Down Expand Up @@ -53,6 +54,8 @@ export function DiffReviewStep({
const setsToArchive = Object.values(orphanResolutions).filter(
(r) => r === "archive",
).length;
const editionChanged =
commitError != null && isEditionChangedError(commitError);
return (
<Card>
<CardHeader>
Expand Down Expand Up @@ -90,22 +93,33 @@ export function DiffReviewStep({
{commitError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Import failed — no changes were saved.</AlertTitle>
<AlertDescription>{commitError}</AlertDescription>
<AlertTitle>
{editionChanged
? "The schedule changed since this review"
: "Import failed — no changes were saved."}
</AlertTitle>
<AlertDescription>
{editionChanged
? "Someone changed this edition's schedule after Analyse ran. Nothing was applied — click Start over to re-run Analyse against the latest data."
: commitError}
</AlertDescription>
</Alert>
)}

<div className="flex gap-3">
<Button variant="outline" onClick={onReset} disabled={committing}>
Start over
</Button>
<Button onClick={onCommit} disabled={!canCommit || committing}>
<Button
onClick={onCommit}
disabled={!canCommit || committing || editionChanged}
>
{committing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Committing…
</>
) : commitError ? (
) : commitError && !editionChanged ? (
"Retry"
) : (
"Commit to database"
Expand Down
8 changes: 8 additions & 0 deletions src/services/scheduleImport/buildCommitPayload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import { buildCommitPayload } from "./buildCommitPayload";
import { type DiffResult } from "./types";

describe("buildCommitPayload", () => {
it("passes the diff's watermark through unchanged", () => {
const diff = makeDiff({ watermark: "7:2026-08-30T12:00:00+00:00" });

const payload = buildCommitPayload(diff, {}, {});
expect(payload.watermark).toBe("7:2026-08-30T12:00:00+00:00");
});

it("passes through clean artistsToCreate/stagesToCreate untouched", () => {
const diff = makeDiff({
cleanOperations: {
Expand Down Expand Up @@ -167,6 +174,7 @@ describe("buildCommitPayload", () => {

function makeDiff(overrides: Partial<DiffResult> = {}): DiffResult {
return {
watermark: "3:2026-08-01T00:00:00+00:00",
summary: {
newArtists: 0,
newStages: 0,
Expand Down
2 changes: 2 additions & 0 deletions src/services/scheduleImport/buildCommitPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function buildCommitPayload(
stageMismatchResolutions: Record<string, StageMismatchResolution>,
orphanResolutions: Record<string, OrphanResolution>,
): {
watermark: string;
artistsToCreate: { name: string; slug: string }[];
stagesToCreate: { name: string }[];
setsToCreate: SetPayload[];
Expand All @@ -33,6 +34,7 @@ export function buildCommitPayload(
.map((s) => s.id);

return {
watermark: diff.watermark,
artistsToCreate: diff.cleanOperations.artistsToCreate,
stagesToCreate: [
...diff.cleanOperations.stagesToCreate,
Expand Down
19 changes: 19 additions & 0 deletions src/services/scheduleImport/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { isEditionChangedError } from "./types";

describe("isEditionChangedError", () => {
it("matches the commit_schedule watermark-mismatch marker", () => {
expect(
isEditionChangedError(
"edition_changed_since_analyse: The schedule changed since this review was generated.",
),
).toBe(true);
});

it("does not match other commit failures", () => {
expect(isEditionChangedError("Stage Mainstage not found in edition")).toBe(
false,
);
expect(isEditionChangedError("")).toBe(false);
});
});
11 changes: 11 additions & 0 deletions src/services/scheduleImport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const setPayloadSchema = z.object({
export type SetPayload = z.infer<typeof setPayloadSchema>;

export const diffResultSchema = z.object({
watermark: z.string(),
summary: z.object({
newArtists: z.number(),
newStages: z.number(),
Expand Down Expand Up @@ -77,3 +78,13 @@ export type StageMismatchResolution =
| { action: "create" };

export type OrphanResolution = "archive" | "keep";

// #42: commit_schedule prefixes its watermark-mismatch exception with this
// marker (see the commit_schedule_watermark migration) so the review step
// can show a dedicated "re-run Analyse" message instead of a generic
// commit-failure alert.
const EDITION_CHANGED_ERROR_PREFIX = "edition_changed_since_analyse:";

export function isEditionChangedError(message: string): boolean {
return message.startsWith(EDITION_CHANGED_ERROR_PREFIX);
}
96 changes: 96 additions & 0 deletions supabase/functions/commit-schedule/commit-schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,20 @@ async function getTestUserId(
return data.user_id;
}

// #42: commit_schedule now requires a watermark matching the edition's
// current state. Fetch it fresh (via the same RPC diff-schedule uses)
// right before each call so unrelated setup inserts above don't go stale.
async function getWatermark(
db: ReturnType<typeof adminClient>,
editionId: string,
): Promise<string> {
const { data, error } = await db.rpc("commit_schedule__compute_watermark", {
p_festival_edition_id: editionId,
});
assertEquals(error, null);
return data as string;
}

Deno.test("commit_schedule: creates new artist and set", async () => {
const db = adminClient();
const editionId = await getTestEditionId(db);
Expand All @@ -49,6 +63,7 @@ Deno.test("commit_schedule: creates new artist and set", async () => {
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [{ name: "Test Artist", slug }],
p_stages_to_create: [],
p_sets_to_create: [
Expand Down Expand Up @@ -112,6 +127,7 @@ Deno.test(
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [],
Expand Down Expand Up @@ -165,6 +181,7 @@ Deno.test("commit_schedule: archives orphaned sets", async () => {
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [],
Expand Down Expand Up @@ -202,6 +219,7 @@ Deno.test(
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [
Expand Down Expand Up @@ -261,6 +279,7 @@ Deno.test(
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [
Expand Down Expand Up @@ -330,6 +349,7 @@ Deno.test(
const { error: overwriteError } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [],
Expand All @@ -349,6 +369,7 @@ Deno.test(
const { error: preserveError } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [],
Expand Down Expand Up @@ -384,6 +405,7 @@ Deno.test(
const { error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: await getWatermark(db, editionId),
p_artists_to_create: [],
p_stages_to_create: [],
p_sets_to_create: [
Expand Down Expand Up @@ -417,3 +439,77 @@ Deno.test(
await db.from("artists").delete().eq("slug", slug);
},
);

Deno.test(
"commit_schedule: aborts and applies nothing when the edition changed since the watermark was computed (#42)",
async () => {
const db = adminClient();
const editionId = await getTestEditionId(db);
const userId = await getTestUserId(db);
const slug = `test-stale-watermark-${Date.now()}`;
const setName = `Stale Watermark Set ${slug}`;

const staleWatermark = await getWatermark(db, editionId);

// Simulate a concurrent edit landing after Analyse: create an unrelated
// set, which changes the edition's watermark.
const { data: concurrentSet, error: concurrentSetError } = await db
.from("sets")
.insert({
festival_edition_id: editionId,
name: "Concurrent Edit",
slug: `concurrent-edit-${Date.now()}`,
created_by: userId,
})
.select("id")
.single();
assertEquals(concurrentSetError, null);
assertExists(concurrentSet);

const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: editionId,
p_user_id: userId,
p_watermark: staleWatermark,
p_artists_to_create: [{ name: "Stale Watermark Artist", slug }],
p_stages_to_create: [],
p_sets_to_create: [
{
name: setName,
description: null,
stageName: null,
timeStart: null,
timeEnd: null,
artistSlugs: [slug],
},
],
p_sets_to_update: [],
p_set_ids_to_archive: [],
});

assertExists(error, "expected commit_schedule to reject a stale watermark");
assertEquals(
error.message.startsWith("edition_changed_since_analyse:"),
true,
`expected the edition_changed_since_analyse marker, got: ${error.message}`,
);
assertEquals(data, null);

// Nothing from the rejected commit was applied — same transaction.
const { data: artist } = await db
.from("artists")
.select("id")
.eq("slug", slug)
.maybeSingle();
assertEquals(artist, null);

const { data: sets } = await db
.from("sets")
.select("id")
.eq("festival_edition_id", editionId)
.eq("name", setName);
assertEquals(sets?.length, 0);

// Cleanup
await db.from("sets").delete().eq("id", concurrentSet!.id);
},
);
6 changes: 6 additions & 0 deletions supabase/functions/commit-schedule/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const setPayloadSchema = z.object({

const commitRequestSchema = z.object({
festivalEditionId: z.string().uuid(),
// #42: the watermark diff-schedule returned at Analyse time, round-tripped
// unchanged. commit_schedule re-validates it against the edition's current
// state and aborts if they diverge.
watermark: z.string().min(1),
artistsToCreate: z
.array(z.object({ name: z.string().min(1), slug: z.string().min(1) }))
.default([]),
Expand Down Expand Up @@ -71,6 +75,7 @@ serve(async (req) => {

const {
festivalEditionId,
watermark,
artistsToCreate,
stagesToCreate,
setsToCreate,
Expand All @@ -83,6 +88,7 @@ serve(async (req) => {
const { data, error } = await db.rpc("commit_schedule", {
p_festival_edition_id: festivalEditionId,
p_user_id: auth.userId,
p_watermark: watermark,
p_artists_to_create: artistsToCreate,
p_stages_to_create: stagesToCreate,
p_sets_to_create: setsToCreate,
Expand Down
Loading
Loading