diff --git a/CONTEXT.md b/CONTEXT.md index ca4ee14d0..2c8647fe9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -33,9 +33,17 @@ A single scheduled happening within an edition, with a stage, a start/end time, _Avoid_: Show, gig, slot, performance (a performance is a _type_ of set, not a synonym for one) **Set type**: -What kind of happening a **set** is: music, workshop, performance, or other. `null` on a set means it predates typing and awaits backfill — never "chose not to say". Voting is identical across types. +What kind of happening a **set** is: music, workshop, performance, or other. `null` on a set means it is not yet typed (it predates typing, or was imported without a type) and awaits backfill — never "chose not to say". Voting is identical across types. _Avoid_: Category, kind +**Roster**: +The artists on a single **set** — one, or several for a B2B. Per-set, where **lineup** is per-edition. A set's roster is its import identity: schedule re-imports match a roster set by its artists (order-insensitive), not by its name. See ADR-0008. +_Avoid_: Lineup (that's the whole edition), billing + +**Artist-less set**: +A **set** with an empty **roster** (e.g. a fire show or an unhosted workshop). Its import identity is its name plus date/stage, unlike a roster set, which is identified by its artists — so adding an artist to a set changes how re-imports match it. See ADR-0008. +_Avoid_: Empty set, unassigned set + **Stage**: A named venue/space within an edition where sets take place. _Avoid_: Venue, room diff --git a/docs/adr/0008-artist-less-set-matching.md b/docs/adr/0008-artist-less-set-matching.md new file mode 100644 index 000000000..6b16acd6d --- /dev/null +++ b/docs/adr/0008-artist-less-set-matching.md @@ -0,0 +1,31 @@ +# Artist-less sets match strictly; roster sets match fuzzily + +Schedule-import matching (issue #433) needed an identity for sets with no +artists, where the roster key doesn't exist. We decided identity differs by +kind: roster rows are identified by their sorted artist slugs, with stage/date +as mere tie-breakers (a set whose day moved is an update); artist-less rows are +identified by name only, so every supplied discriminator must actually hold — +a stored stage or date that contradicts the row excludes the candidate, and no +survivor means a create. The alternative (one fuzzy rule for both) silently +updated the wrong set whenever a name like "Fire Show" recurred across days. + +Settled in design review (2026-08-28) alongside three boundary decisions: a +roster _change_ is a new identity (a solo set gaining a B2B partner creates a +new set and orphans the old one — votes do not carry over); renaming an +artist-less set must happen in the app, since a CSV rename reads as +create-new + orphan-old; and the artist-less/roster boundary is hard in both +directions (crediting a performer to a formerly artist-less set, or removing +the last artist, changes identity). The orphan review step is the safety net +for all three. + +## Consequences + +- The two index spaces never cross: a roster row cannot match a 0-artist set, + nor the reverse — adding an artist to a set changes its import identity. +- Candidates with no stored time/stage still match, so time-less rows survive + re-import without duplicating; the cost is that such sets can't coexist with + a dated same-name set unambiguously. +- A fuzzy-matched CSV stage provisionally stands in for its closest DB stage + during matching, before the user resolves the mismatch — wrong-set selection + is possible if they map it elsewhere (#447, with an ignored marker test in + `computeDiff.artistless.test.ts`). diff --git a/docs/schedule-import.md b/docs/schedule-import.md new file mode 100644 index 000000000..1210c4553 --- /dev/null +++ b/docs/schedule-import.md @@ -0,0 +1,113 @@ +# Schedule import: how it works + +The schedule import wizard (`Admin → Schedule import`) takes a CSV and turns it +into creates/updates/archives of an edition's sets. This doc explains the +pipeline end-to-end, the matching rules, and what adding a new CSV column costs. + +## The pipeline, end to end + +``` +CSV file + │ parseScheduleCsv (client) src/services/scheduleImport/parseCsv.ts + ▼ +CsvRow[] — parsed, validated rows + │ diff-schedule (edge function) supabase/functions/diff-schedule/ + ▼ +DiffResult — creates / updates / orphans / conflicts + │ DiffReviewStep (client UI) src/components/Admin/ScheduleImport/ + │ user resolves stage mismatches and orphan handling + ▼ +CommitPayload + │ buildCommitPayload (client) → commit-schedule (edge function) + ▼ +commit_schedule RPC (Postgres) supabase/migrations/…commit_schedule… +``` + +1. **Parse (client).** `parseScheduleCsv` reads the CSV with papaparse. + Recognized columns: `Artists` (pipe-separated for B2B), `Set Name`, `Stage`, + `Date`, `Start Time`, `End Time`, `Description`, `Type`. Rows with neither + artists nor a set name are discarded. Validation (unknown `Type` values, + un-sluggable names) runs only on rows that survive the discard filter. +2. **Diff (edge).** `diff-schedule` loads the edition's current sets, stages, + and artists, then walks the CSV rows through `computeDiff`. Each row either + matches an existing set (→ update) or doesn't (→ create). DB sets no CSV row + matched become _orphans_ (the user chooses archive/keep). Stage names that + only fuzzy-match a DB stage become _mismatches_ for the user to resolve. +3. **Review (client).** The diff is shown before anything is written: summary + counts, new artists, typed-set chips (stored → incoming), orphans, and stage + mismatches. +4. **Commit.** The confirmed operations go through `commit-schedule` into the + `commit_schedule` RPC, which applies everything in one transaction. + +## Matching rules: which DB set does a row update? + +Matching is the heart of the diff and the only genuinely subtle part. There are +two modes, chosen by whether the row has artists (see ADR-0008): + +**Roster rows (has artists) — fuzzy.** Identity is the _artist roster_: rows +and sets are keyed by their sorted artist slugs, so "Carl Cox" finds the Carl +Cox set no matter how the name is spelled. Stage and date are only +_tie-breakers_ when several sets share a roster (narrow by stage, then by date +within the stage matches, then by set name as a last resort — names are the +most volatile column; a tie-breaker matching nothing is skipped rather than +emptying the pool). A roster row whose stage or date changed still matches — +that's an update, not a new set. + +**Artist-less rows (no artists) — strict.** Identity is the _name_ (trimmed, +case-insensitive), which is weak — "Fire Show" can legitimately exist twice on +different days. So a supplied stage or date must actually hold: a candidate +whose stored stage or date contradicts the row is excluded outright, and if +nothing survives the row becomes a create. Candidates with _no_ stored +time/stage still match, so re-importing a time-less row doesn't duplicate it. +A CSV stage that is _new_ excludes every staged candidate; a _fuzzy-matched_ +stage provisionally stands in for its closest DB stage (known limitation: +issue #447). + +The two index spaces never cross: a roster row can't match a 0-artist set and +vice versa. Within one import, each DB set is matched at most once. + +Boundary consequences (all deliberate, see ADR-0008): a roster _change_ is a +new identity — "Carl Cox" becoming "Carl Cox | Peggy Gou" creates a new set +and orphans the solo one, votes don't carry; renaming an artist-less set must +happen in the app, not the CSV (a CSV rename is create + orphan); crediting a +performer to a formerly artist-less set (or removing the last artist) also +changes identity. The orphan review is the safety net in every case. + +A CSV import is a **full snapshot** of the schedule, never a partial add: any +DB set absent from the CSV is surfaced as an orphan and you choose archive or +keep, one by one. + +## Type semantics + +- `Type` blank or column absent → `null`; invalid value → parse error. +- On commit, an explicit type overwrites the stored one; `null` preserves it + (`COALESCE` in the RPC). Consequence: an import can never clear a type back + to `null` — clearing (if ever needed) is an in-app action, deliberately not + a CSV sentinel value. + +## Adding a new CSV column: the checklist + +A plain passthrough column (parsed, carried, written — no matching semantics) +is mechanical. It touches the contract in seven places; missing the client Zod +schema is the classic mistake (non-strict `z.object` silently strips unknown +keys): + +1. `src/services/scheduleImport/parseCsv.ts` — parse + validate (+ tests) +2. `src/services/scheduleImport/types.ts` — `CsvRow` + `setPayloadSchema` + (+ `diffResultSchema` if the diff returns it) +3. `supabase/functions/diff-schedule/types.ts` — `CsvRow`, `SetPayload`, + `DbSet` if read back +4. `supabase/functions/diff-schedule/index.ts` — request schema + DB select +5. `supabase/functions/diff-schedule/computeDiff.ts` — into the payload +6. `supabase/functions/commit-schedule/index.ts` — payload schema +7. `supabase/migrations/` — new migration redefining the `commit_schedule__*` + helpers that write the column + (+ UI in `src/components/Admin/ScheduleImport/` if it should be visible) + +Issue #448 tracks collapsing the duplicated halves of this contract so the +list gets shorter. + +A column that participates in _identity_ (affects which set a row matches) or +has overwrite/preserve semantics is a different kind of change: it lands in +`resolvers.ts`/`computeDiff.ts` and needs the same red-green treatment the +artist-less matching got. Budget accordingly. diff --git a/src/components/Admin/ScheduleImport/CsvDropZone.tsx b/src/components/Admin/ScheduleImport/CsvDropZone.tsx index 43734ba38..3716acc52 100644 --- a/src/components/Admin/ScheduleImport/CsvDropZone.tsx +++ b/src/components/Admin/ScheduleImport/CsvDropZone.tsx @@ -50,9 +50,13 @@ export function CsvDropZone({ fileName, rowCount, onFileSelected }: Props) {

Required column: Artists (use | for B2B, e.g.{" "} Carl Cox | Peggy Gou). Optional: Set Name,{" "} + Type (music, workshop, performance or other),{" "} Stage, Date (YYYY-MM-DD),{" "} Start Time (HH:MM), End Time (HH:MM),{" "} - Description. + Description. Rows without artists are kept when they have a{" "} + Set Name (e.g. workshops). The CSV is treated as the + complete schedule: existing sets missing from it are flagged for + archiving in the review step.

); diff --git a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx index e80b44e3e..bad1893ca 100644 --- a/src/components/Admin/ScheduleImport/DiffReviewStep.tsx +++ b/src/components/Admin/ScheduleImport/DiffReviewStep.tsx @@ -9,6 +9,7 @@ import { } from "@/services/scheduleImport/types"; import type { RevealLevel } from "@/lib/scheduleReveal"; import { DiffSummaryBanner } from "./DiffSummaryBanner"; +import { TypedSetsPanel } from "./TypedSetsPanel"; import { StageMismatchResolver } from "./StageMismatchResolver"; import { OrphanedSetsPanel } from "./OrphanedSetsPanel"; import { LiveCommitWarning } from "./LiveCommitWarning"; @@ -60,6 +61,11 @@ export function DiffReviewStep({ + + { + it("renders nothing when no set carries a type", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("lists only genuine type changes, with stored and incoming chips", () => { + render( + , + ); + expect(screen.getByText("1 set changing type")).toBeVisible(); + expect(screen.getByText("Fire Show")).toBeVisible(); + expect(screen.getByText("Music")).toBeVisible(); + expect(screen.getByText("Performance")).toBeVisible(); + expect(screen.queryByText("Morning Yoga")).not.toBeInTheDocument(); + expect(screen.queryByText("Peggy Gou")).not.toBeInTheDocument(); + expect(screen.getByText(/2 more rows carry a type/)).toBeVisible(); + }); + + it("summarizes typed rows that change nothing without listing them", () => { + render( + , + ); + expect(screen.getByText("Set types from the CSV")).toBeVisible(); + expect(screen.getByText(/2 rows carry a type/)).toBeVisible(); + expect(screen.queryByText("Morning Yoga")).not.toBeInTheDocument(); + expect(screen.queryByText("Fire Show")).not.toBeInTheDocument(); + }); + + it("shows a first-time type as changing nothing", () => { + render( + , + ); + expect(screen.getByText("Set types from the CSV")).toBeVisible(); + expect(screen.getByText(/1 row carries a type/)).toBeVisible(); + expect(screen.queryByText("Fire Show")).not.toBeInTheDocument(); + }); +}); + +function makePayload(name: string): SetPayload { + return { + name, + setType: null, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }; +} diff --git a/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx new file mode 100644 index 000000000..089423027 --- /dev/null +++ b/src/components/Admin/ScheduleImport/TypedSetsPanel.tsx @@ -0,0 +1,77 @@ +import { ArrowRight, Tags } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import type { SetType } from "@/api/sets/types"; +import { cn } from "@/lib/utils"; +import { getSetTypeLabel } from "@/lib/setTypeLabels"; +import { type DiffResult } from "@/services/scheduleImport/types"; + +type SetToUpdate = DiffResult["cleanOperations"]["setsToUpdate"][number]; + +type Props = { + setsToCreate: DiffResult["cleanOperations"]["setsToCreate"]; + setsToUpdate: DiffResult["cleanOperations"]["setsToUpdate"]; +}; + +export function TypedSetsPanel({ setsToCreate, setsToUpdate }: Props) { + const typedCreateCount = setsToCreate.filter( + (s) => s.setType !== null, + ).length; + const typedUpdates = setsToUpdate.filter((s) => s.setType !== null); + const typeChanges = typedUpdates.filter(isTypeChange); + const keptCount = typedCreateCount + typedUpdates.length - typeChanges.length; + + if (typedCreateCount + typedUpdates.length === 0) return null; + + return ( +
+
+ + {typeChanges.length > 0 + ? `${typeChanges.length} set${typeChanges.length !== 1 ? "s" : ""} changing type` + : "Set types from the CSV"} +
+ +

+ {keptCount > 0 && + `${keptCount} ${typeChanges.length > 0 ? "more " : ""}${keptCount !== 1 ? "rows carry" : "row carries"} a type that changes nothing (new sets, or updates matching the stored type). `} + Rows with a blank type keep whatever type the matched set already has. +

+ + {typeChanges.length > 0 && ( +
+ {typeChanges.map((set) => ( +
+

{set.name}

+
+ + + +
+
+ ))} +
+ )} +
+ ); +} + +/** + * A stored type differing from the incoming one is the change worth + * verifying; new sets and first-time types just take the CSV value. + */ +function isTypeChange(set: SetToUpdate): boolean { + return set.previousSetType !== null && set.previousSetType !== set.setType; +} + +function SetTypeChip({ setType }: { setType: SetType | null }) { + const { icon: Icon, label, color } = getSetTypeLabel(setType); + return ( + + + {label} + + ); +} diff --git a/src/services/scheduleImport/buildCommitPayload.test.ts b/src/services/scheduleImport/buildCommitPayload.test.ts index 1290480d2..708140f3a 100644 --- a/src/services/scheduleImport/buildCommitPayload.test.ts +++ b/src/services/scheduleImport/buildCommitPayload.test.ts @@ -50,6 +50,7 @@ describe("buildCommitPayload", () => { setsToCreate: [ { name: "Carl Cox", + setType: null, description: null, stageName: "Mainstage", timeStart: null, @@ -88,6 +89,7 @@ describe("buildCommitPayload", () => { setsToCreate: [ { name: "Carl Cox", + setType: null, description: null, stageName: "Main Stage", timeStart: null, @@ -103,6 +105,44 @@ describe("buildCommitPayload", () => { expect(payload.setsToCreate[0].stageName).toBe("Main Stage"); }); + it("passes setType and empty rosters through for creates and updates", () => { + const diff = makeDiff({ + cleanOperations: { + artistsToCreate: [], + stagesToCreate: [], + setsToCreate: [ + { + name: "Morning Yoga", + setType: "workshop", + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }, + ], + setsToUpdate: [ + { + id: "set-1", + name: "Fire Show", + setType: null, + previousSetType: "performance", + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }, + ], + }, + }); + + const payload = buildCommitPayload(diff, {}, {}); + expect(payload.setsToCreate[0].setType).toBe("workshop"); + expect(payload.setsToCreate[0].artistSlugs).toEqual([]); + expect(payload.setsToUpdate[0].setType).toBeNull(); + }); + it("filters orphan archive ids based on resolutions", () => { const diff = makeDiff({ conflicts: { diff --git a/src/services/scheduleImport/parseCsv.test.ts b/src/services/scheduleImport/parseCsv.test.ts index e93d8aa5d..7528c124c 100644 --- a/src/services/scheduleImport/parseCsv.test.ts +++ b/src/services/scheduleImport/parseCsv.test.ts @@ -11,6 +11,7 @@ describe("parseScheduleCsv", () => { expect(parseScheduleCsv(csv)).toEqual([ { artists: ["Carl Cox"], + setType: null, setName: "Carl Cox Live", stage: "Main Stage", date: "2026-07-11", @@ -31,6 +32,7 @@ describe("parseScheduleCsv", () => { expect(parseScheduleCsv(csv)).toEqual([ { artists: ["DJ Tennis"], + setType: null, setName: undefined, stage: undefined, date: "2026-07-12", @@ -66,6 +68,63 @@ describe("parseScheduleCsv", () => { expect(parseScheduleCsv(csv)[0].artists).toEqual(["Carl Cox", "Peggy Gou"]); }); + it("parses a valid type value", () => { + const csv = ["Artists,Type", "Carl Cox,workshop"].join("\n"); + expect(parseScheduleCsv(csv)[0].setType).toBe("workshop"); + }); + + it("normalizes type casing and whitespace", () => { + const csv = ["Artists,Type", "Carl Cox, Workshop "].join("\n"); + expect(parseScheduleCsv(csv)[0].setType).toBe("workshop"); + }); + + it("parses a blank type as null", () => { + const csv = ["Artists,Type", "Carl Cox,"].join("\n"); + expect(parseScheduleCsv(csv)[0].setType).toBeNull(); + }); + + it("parses a missing type column as null", () => { + const csv = ["Artists,Stage", "Carl Cox,Main"].join("\n"); + expect(parseScheduleCsv(csv)[0].setType).toBeNull(); + }); + + it("ignores an invalid type on a row that is skipped anyway", () => { + const csv = ["Artists,Set Name,Type", "Carl Cox,,music", ",,concert"].join( + "\n", + ); + expect(parseScheduleCsv(csv)).toHaveLength(1); + }); + + it("throws on an invalid type value", () => { + const csv = ["Artists,Type", "Carl Cox,concert"].join("\n"); + expect(() => parseScheduleCsv(csv)).toThrow(/Invalid type "concert"/); + }); + + it("keeps artist-less rows that have a set name", () => { + const csv = [ + "Artists,Set Name,Type", + ",Morning Yoga,workshop", + "Carl Cox,,", + ].join("\n"); + const rows = parseScheduleCsv(csv); + expect(rows).toHaveLength(2); + expect(rows[0].artists).toEqual([]); + expect(rows[0].setName).toBe("Morning Yoga"); + expect(rows[0].setType).toBe("workshop"); + }); + + it("still skips rows with neither artists nor a set name", () => { + const csv = ["Artists,Set Name,Stage", "Carl Cox,,Main", ",,Side"].join( + "\n", + ); + expect(parseScheduleCsv(csv)).toHaveLength(1); + }); + + it("throws when an artist-less row's set name has no letters or digits", () => { + const csv = ["Artists,Set Name", ",???"].join("\n"); + expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/); + }); + it("throws when an artist name has no letters or digits", () => { const csv = ["Artists,Stage", "!!!,Main"].join("\n"); expect(() => parseScheduleCsv(csv)).toThrow(/no letters or digits/); diff --git a/src/services/scheduleImport/parseCsv.ts b/src/services/scheduleImport/parseCsv.ts index dac84f489..c53363d48 100644 --- a/src/services/scheduleImport/parseCsv.ts +++ b/src/services/scheduleImport/parseCsv.ts @@ -1,4 +1,5 @@ import Papa from "papaparse"; +import { asSetType } from "@/api/sets/types"; import { type CsvRow } from "./types"; export function parseScheduleCsv(csvContent: string): CsvRow[] { @@ -30,7 +31,7 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] { const endTime = row["end time"]?.trim() || undefined; const description = row.description?.trim() || undefined; - const csvRow: CsvRow = { artists }; + const csvRow: CsvRow = { artists, setType: null }; if (setName !== undefined) csvRow.setName = setName; if (stage !== undefined) csvRow.stage = stage; if (date !== undefined) csvRow.date = date; @@ -38,9 +39,17 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] { if (endTime !== undefined) csvRow.endTime = endTime; if (description !== undefined) csvRow.description = description; - return csvRow; + return { csvRow, rawType: row.type }; }) - .filter((row) => row.artists.length > 0); + .filter( + ({ csvRow }) => csvRow.artists.length > 0 || csvRow.setName !== undefined, + ) + // Validate the type only on rows that survive the filter, so a discarded + // row (no artists, no set name) can't abort the import over a bad type. + .map(({ csvRow, rawType }) => ({ + ...csvRow, + setType: parseSetType(rawType), + })); for (const row of rows) { for (const artist of row.artists) { @@ -50,6 +59,11 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] { ); } } + if (row.artists.length === 0 && !hasSluggableChars(row.setName ?? "")) { + throw new Error( + `Set name "${row.setName}" has no letters or digits and can't be imported.`, + ); + } if (row.stage && !hasSluggableChars(row.stage)) { throw new Error( `Stage name "${row.stage}" has no letters or digits and can't be imported.`, @@ -60,6 +74,18 @@ export function parseScheduleCsv(csvContent: string): CsvRow[] { return rows; } +function parseSetType(raw: string | undefined): CsvRow["setType"] { + const value = raw?.trim().toLowerCase(); + if (!value) return null; + const setType = asSetType(value); + if (setType === null) { + throw new Error( + `Invalid type "${raw?.trim()}" — use music, workshop, performance or other, or leave it blank.`, + ); + } + return setType; +} + // A name with no [a-z0-9] slugifies to an empty string, which downstream // breaks slug-based lookups and the slug unique constraints. Reject it here // with a clear message instead of failing opaquely at commit time. diff --git a/src/services/scheduleImport/types.ts b/src/services/scheduleImport/types.ts index 77ac10d05..e6ce8560b 100644 --- a/src/services/scheduleImport/types.ts +++ b/src/services/scheduleImport/types.ts @@ -1,7 +1,9 @@ import { z } from "zod"; +import { SET_TYPES, type SetType } from "@/api/sets/types"; export type CsvRow = { artists: string[]; + setType: SetType | null; setName?: string; stage?: string; date?: string; @@ -12,6 +14,7 @@ export type CsvRow = { export const setPayloadSchema = z.object({ name: z.string(), + setType: z.enum(SET_TYPES).nullable(), description: z.string().nullable(), stageName: z.string().nullable(), timeStart: z.string().nullable(), @@ -33,7 +36,14 @@ export const diffResultSchema = z.object({ artistsToCreate: z.array(z.object({ name: z.string(), slug: z.string() })), stagesToCreate: z.array(z.object({ name: z.string() })), setsToCreate: z.array(setPayloadSchema), - setsToUpdate: z.array(setPayloadSchema.extend({ id: z.string() })), + setsToUpdate: z.array( + setPayloadSchema.extend({ + id: z.string(), + // The matched set's stored type, so the review can render + // stored → incoming chips. Not written on commit. + previousSetType: z.enum(SET_TYPES).nullable(), + }), + ), }), conflicts: z.object({ stageNameMismatches: z.array( diff --git a/supabase/functions/_shared/setTypes.ts b/supabase/functions/_shared/setTypes.ts new file mode 100644 index 000000000..15b4960c2 --- /dev/null +++ b/supabase/functions/_shared/setTypes.ts @@ -0,0 +1,11 @@ +/** + * Mirrors SET_TYPES in src/api/sets/types.ts and the sets_set_type_check + * constraint. Keep the three in sync when adding a type. + */ +export const SET_TYPES = ["music", "workshop", "performance", "other"] as const; + +export type SetType = (typeof SET_TYPES)[number]; + +export function asSetType(value: string | null): SetType | null { + return SET_TYPES.includes(value as SetType) ? (value as SetType) : null; +} diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts index dc60ec854..328aef838 100644 --- a/supabase/functions/commit-schedule/commit-schedule.test.ts +++ b/supabase/functions/commit-schedule/commit-schedule.test.ts @@ -250,6 +250,125 @@ Deno.test( }, ); +Deno.test( + "commit_schedule: creates an artist-less typed set with an empty roster", + async () => { + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const setName = `Morning Yoga ${Date.now()}`; + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [ + { + name: setName, + setType: "workshop", + description: "Sun salutations", + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }, + ], + p_sets_to_update: [], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + assertEquals(data.setsCreated, 1); + + const { data: sets } = await db + .from("sets") + .select("id, set_type, set_artists(artist_id)") + .eq("festival_edition_id", editionId) + .eq("name", setName); + + assertExists(sets?.[0]); + assertEquals(sets![0].set_type, "workshop"); + assertEquals(sets![0].set_artists.length, 0); + + // Cleanup + await db.from("sets").delete().eq("id", sets![0].id); + }, +); + +Deno.test( + "commit_schedule: explicit type overwrites, null type preserves", + async () => { + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const setName = `Type Roundtrip ${Date.now()}`; + + const { data: set } = await db + .from("sets") + .insert({ + festival_edition_id: editionId, + name: setName, + slug: `type-roundtrip-${Date.now()}`, + set_type: "performance", + created_by: userId, + }) + .select("id") + .single(); + + const basePayload = { + id: set!.id, + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }; + + // Explicit type overwrites the stored one. + const { error: overwriteError } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [], + p_sets_to_update: [{ ...basePayload, setType: "workshop" }], + p_set_ids_to_archive: [], + }); + assertEquals(overwriteError, null); + + const { data: afterOverwrite } = await db + .from("sets") + .select("set_type") + .eq("id", set!.id) + .single(); + assertEquals(afterOverwrite!.set_type, "workshop"); + + // Null type (blank CSV column) preserves the stored one. + const { error: preserveError } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [], + p_sets_to_update: [{ ...basePayload, setType: null }], + p_set_ids_to_archive: [], + }); + assertEquals(preserveError, null); + + const { data: afterPreserve } = await db + .from("sets") + .select("set_type") + .eq("id", set!.id) + .single(); + assertEquals(afterPreserve!.set_type, "workshop"); + + // Cleanup + await db.from("sets").delete().eq("id", set!.id); + }, +); + Deno.test( "commit_schedule: midnight-crossing times stored correctly", async () => { diff --git a/supabase/functions/commit-schedule/index.ts b/supabase/functions/commit-schedule/index.ts index 705484327..5ed2708ff 100644 --- a/supabase/functions/commit-schedule/index.ts +++ b/supabase/functions/commit-schedule/index.ts @@ -2,6 +2,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; import { requireAdmin } from "../_shared/auth.ts"; import { buildCorsHeaders } from "../_shared/cors.ts"; +import { SET_TYPES } from "../_shared/setTypes.ts"; // timeStart/timeEnd arrive as ISO strings or null. Coerce "" (and undefined) // to null so the RPC's ::timestamptz cast doesn't choke on an empty string. @@ -10,13 +11,19 @@ const nullableTimestamp = z .nullish() .transform((v) => v || null); +// An explicit empty artistSlugs array is a valid roster: artist-less sets +// (workshops, performances) carry no artists by design. const setPayloadSchema = z.object({ name: z.string().min(1), + setType: z + .enum(SET_TYPES) + .nullish() + .transform((v) => v ?? null), description: z.string().nullish(), stageName: z.string().nullish(), timeStart: nullableTimestamp, timeEnd: nullableTimestamp, - artistSlugs: z.array(z.string().min(1)).min(1), + artistSlugs: z.array(z.string().min(1)), }); const commitRequestSchema = z.object({ diff --git a/supabase/functions/diff-schedule/computeDiff.artistless.test.ts b/supabase/functions/diff-schedule/computeDiff.artistless.test.ts new file mode 100644 index 000000000..ca47d83e9 --- /dev/null +++ b/supabase/functions/diff-schedule/computeDiff.artistless.test.ts @@ -0,0 +1,298 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { computeDiff } from "./computeDiff.ts"; +import { makeArtist, makeSet, makeStage } from "./fixtures.ts"; + +Deno.test("artist-less row creates a set with an empty roster", () => { + const result = computeDiff( + [{ artists: [], setName: "Morning Yoga", setType: "workshop" }], + [], + [], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.cleanOperations.setsToCreate[0].name, "Morning Yoga"); + assertEquals(result.cleanOperations.setsToCreate[0].artistSlugs, []); + assertEquals(result.cleanOperations.setsToCreate[0].setType, "workshop"); + assertEquals(result.summary.newArtists, 0); +}); + +Deno.test("artist-less row matches an existing 0-artist set by name", () => { + const set = makeSet("set-yoga", "Morning Yoga", []); + const result = computeDiff( + [{ artists: [], setName: "morning yoga" }], + [], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-yoga"); + assertEquals(result.cleanOperations.setsToCreate.length, 0); + assertEquals(result.conflicts.orphanedSets.length, 0); +}); + +Deno.test( + "artist-less row does not match a same-name set that has artists", + () => { + const artist = makeArtist("Carl Cox"); + const set = makeSet("set-cox", "Morning Yoga", [artist]); + const result = computeDiff( + [{ artists: [], setName: "Morning Yoga" }], + [], + [set], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.conflicts.orphanedSets.length, 1); + }, +); + +Deno.test("roster row does not match a 0-artist set", () => { + const artist = makeArtist("Carl Cox"); + const set = makeSet("set-empty", "Carl Cox", []); + const result = computeDiff( + [{ artists: ["Carl Cox"] }], + [], + [set], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.conflicts.orphanedSets.length, 1); +}); + +Deno.test("same-name artist-less candidates disambiguated by stage", () => { + const stage1 = makeStage("s1", "Stage One"); + const stage2 = makeStage("s2", "Stage Two"); + const set1 = makeSet("set-a", "Fire Show", [], "s1"); + const set2 = makeSet("set-b", "Fire Show", [], "s2"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Stage Two" }], + [stage1, stage2], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + assertEquals(result.conflicts.orphanedSets.length, 1); + assertEquals(result.conflicts.orphanedSets[0].id, "set-a"); +}); + +Deno.test( + "same name and stage on different dates disambiguated by date", + () => { + const stage = makeStage("s1", "Workshop Tent"); + const set1 = makeSet( + "set-a", + "Fire Show", + [], + "s1", + "2026-07-11T20:00:00Z", + ); + const set2 = makeSet( + "set-b", + "Fire Show", + [], + "s1", + "2026-07-12T20:00:00Z", + ); + const result = computeDiff( + [ + { + artists: [], + setName: "Fire Show", + stage: "Workshop Tent", + date: "2026-07-12", + }, + ], + [stage], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + }, +); + +Deno.test("same-name artist-less candidates disambiguated by date", () => { + const set1 = makeSet("set-a", "Fire Show", [], null, "2026-07-11T20:00:00Z"); + const set2 = makeSet("set-b", "Fire Show", [], null, "2026-07-12T20:00:00Z"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", date: "2026-07-12" }], + [], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); +}); + +Deno.test( + "artist-less row on a different date creates instead of updating", + () => { + const set = makeSet("set-a", "Fire Show", [], null, "2026-07-11T20:00:00Z"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", date: "2026-07-12" }], + [], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.conflicts.orphanedSets.length, 1); + assertEquals(result.conflicts.orphanedSets[0].id, "set-a"); + }, +); + +Deno.test( + "artist-less row on a different stage creates instead of updating", + () => { + const stage1 = makeStage("s1", "Stage One"); + const stage2 = makeStage("s2", "Stage Two"); + const set = makeSet("set-a", "Fire Show", [], "s1"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Stage Two" }], + [stage1, stage2], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.conflicts.orphanedSets.length, 1); + }, +); + +Deno.test( + "artist-less row with a date still matches a stored set without a time", + () => { + const set = makeSet("set-a", "Fire Show", []); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", date: "2026-07-12" }], + [], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-a"); + }, +); + +Deno.test( + "artist-less row at a new stage creates instead of updating a staged set", + () => { + const stage = makeStage("s1", "Stage One"); + const set = makeSet("set-a", "Fire Show", [], "s1"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Secret Forest" }], + [stage], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + assertEquals(result.conflicts.orphanedSets.length, 1); + }, +); + +Deno.test( + "artist-less row at a new stage still matches a stage-less stored set", + () => { + const set = makeSet("set-a", "Fire Show", []); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Secret Forest" }], + [], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-a"); + }, +); + +// Pins the provisional closest-stage behavior; see #447 for its limit. +Deno.test( + "mismatched stage matches artist-less sets via its closest stage", + () => { + const stage1 = makeStage("s1", "Main Stage"); + const stage2 = makeStage("s2", "Side"); + const set1 = makeSet("set-a", "Fire Show", [], "s2"); + const set2 = makeSet("set-b", "Fire Show", [], "s1"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Mainstage" }], + [stage1, stage2], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + }, +); + +// Executable marker for #447: un-ignore once artist-less matching honors the +// user's stage-mismatch resolution instead of the closest-match guess pinned +// above. Encodes the conservative outcome (don't pick between staged +// candidates while the mismatch is unresolved); adjust it if #447 settles on +// passing resolutions into the diff instead. +Deno.test({ + name: "unresolved stage mismatch defers artist-less set selection (#447)", + ignore: true, + fn() { + const stage1 = makeStage("s1", "Main Stage"); + const stage2 = makeStage("s2", "Main Stage East"); + const set1 = makeSet("set-a", "Fire Show", [], "s1"); + const set2 = makeSet("set-b", "Fire Show", [], "s2"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Mainstage" }], + [stage1, stage2], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + }, +}); + +Deno.test( + "mismatched stage excludes an artist-less set at a different stage", + () => { + const stage1 = makeStage("s1", "Main Stage"); + const stage2 = makeStage("s2", "Side"); + const set = makeSet("set-a", "Fire Show", [], "s2"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", stage: "Mainstage" }], + [stage1, stage2], + [set], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 0); + assertEquals(result.cleanOperations.setsToCreate.length, 1); + }, +); + +Deno.test("dated artist-less candidate preferred over an undated one", () => { + const set1 = makeSet("set-a", "Fire Show", []); + const set2 = makeSet("set-b", "Fire Show", [], null, "2026-07-12T20:00:00Z"); + const result = computeDiff( + [{ artists: [], setName: "Fire Show", date: "2026-07-12" }], + [], + [set1, set2], + [], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); +}); diff --git a/supabase/functions/diff-schedule/computeDiff.test.ts b/supabase/functions/diff-schedule/computeDiff.test.ts index e2a71d154..40cce84e7 100644 --- a/supabase/functions/diff-schedule/computeDiff.test.ts +++ b/supabase/functions/diff-schedule/computeDiff.test.ts @@ -1,6 +1,6 @@ import { assertEquals } from "jsr:@std/assert@1"; import { computeDiff } from "./computeDiff.ts"; -import type { DbArtist, DbSet, DbStage } from "./types.ts"; +import { makeArtist, makeSet, makeStage } from "./fixtures.ts"; Deno.test("new artist in CSV creates artist", () => { const result = computeDiff( @@ -236,29 +236,146 @@ Deno.test("multiple candidates disambiguated by stage", () => { assertEquals(result.conflicts.orphanedSets[0].id, "set-a"); }); -function makeArtist(name: string): DbArtist { - const slug = name.toLowerCase().replace(/\s+/g, "-"); - return { id: `id-${slug}`, name, slug }; -} +Deno.test( + "same-roster candidates disambiguated by set name as last resort", + () => { + const artist = makeArtist("Carl Cox"); + const set1 = makeSet("set-a", "Carl Cox Live", [artist]); + const set2 = makeSet("set-b", "Carl Cox DJ Set", [artist]); + const result = computeDiff( + [{ artists: ["Carl Cox"], setName: "Carl Cox DJ Set" }], + [], + [set1, set2], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + }, +); -function makeStage(id: string, name: string): DbStage { - return { id, name }; -} +Deno.test("date narrowing beats a set-name match for roster rows", () => { + const artist = makeArtist("Carl Cox"); + const set1 = makeSet( + "set-a", + "Carl Cox Live", + [artist], + null, + "2026-07-11T20:00:00Z", + ); + const set2 = makeSet( + "set-b", + "Carl Cox Sunset", + [artist], + null, + "2026-07-12T20:00:00Z", + ); + const result = computeDiff( + [ + { + artists: ["Carl Cox"], + setName: "Carl Cox Live", + date: "2026-07-12", + }, + ], + [], + [set1, set2], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); +}); -function makeSet( - id: string, - name: string, - artists: DbArtist[], - stageId: string | null = null, - timeStart: string | null = null, -): DbSet { - return { - id, - name, - description: null, - stage_id: stageId, - time_start: timeStart, - time_end: null, - set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })), - }; -} +Deno.test( + "same-roster sets on one stage across dates matched by the row's date", + () => { + const artist = makeArtist("Carl Cox"); + const stage = makeStage("s1", "Stage One"); + const set1 = makeSet( + "set-a", + "Carl Cox", + [artist], + "s1", + "2026-07-11T20:00:00Z", + ); + const set2 = makeSet( + "set-b", + "Carl Cox", + [artist], + "s1", + "2026-07-12T20:00:00Z", + ); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Stage One", date: "2026-07-12" }], + [stage], + [set1, set2], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + }, +); + +Deno.test( + "roster row's stage matching nothing falls back to date narrowing", + () => { + const artist = makeArtist("Carl Cox"); + const stage1 = makeStage("s1", "Stage One"); + const stage2 = makeStage("s2", "Stage Two"); + const stage3 = makeStage("s3", "Stage Three"); + const set1 = makeSet( + "set-a", + "Carl Cox", + [artist], + "s1", + "2026-07-11T20:00:00Z", + ); + const set2 = makeSet( + "set-b", + "Carl Cox", + [artist], + "s2", + "2026-07-12T20:00:00Z", + ); + const result = computeDiff( + [{ artists: ["Carl Cox"], stage: "Stage Three", date: "2026-07-12" }], + [stage1, stage2, stage3], + [set1, set2], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate.length, 1); + assertEquals(result.cleanOperations.setsToUpdate[0].id, "set-b"); + }, +); + +Deno.test( + "row setType lands in the payload; absent setType becomes null", + () => { + const result = computeDiff( + [{ artists: ["Carl Cox"], setType: "music" }, { artists: ["Peggy Gou"] }], + [], + [], + [makeArtist("Carl Cox"), makeArtist("Peggy Gou")], + "UTC", + ); + assertEquals(result.cleanOperations.setsToCreate[0].setType, "music"); + assertEquals(result.cleanOperations.setsToCreate[1].setType, null); + }, +); + +Deno.test("update payload carries the matched set's stored type", () => { + const artist = makeArtist("Carl Cox"); + const set = { ...makeSet("set-1", "Carl Cox", [artist]), set_type: "music" }; + const result = computeDiff( + [{ artists: ["Carl Cox"], setType: "workshop" }], + [], + [set], + [artist], + "UTC", + ); + assertEquals(result.cleanOperations.setsToUpdate[0].previousSetType, "music"); + assertEquals(result.cleanOperations.setsToUpdate[0].setType, "workshop"); +}); diff --git a/supabase/functions/diff-schedule/computeDiff.ts b/supabase/functions/diff-schedule/computeDiff.ts index c5f2e65db..883d78944 100644 --- a/supabase/functions/diff-schedule/computeDiff.ts +++ b/supabase/functions/diff-schedule/computeDiff.ts @@ -1,7 +1,9 @@ +import { asSetType } from "../_shared/setTypes.ts"; import { artistKey } from "./helpers.ts"; import { buildIndexes, computeTimes, + findMatchingArtistlessSet, findMatchingSet, resolveArtists, resolveStage, @@ -38,18 +40,25 @@ export function computeDiff( const { timeStart, timeEnd } = computeTimes(row, timezone); - const candidates = - indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? []; - const matched = findMatchingSet( - candidates, - resolvedStage.id, - row.date, - timezone, - state.matchedSetIds, - ); + const name = row.setName?.trim() || row.artists.join(" b2b "); + + const matchContext = { stage, date: row.date, timezone, name }; + const matched = + row.artists.length === 0 + ? findMatchingArtistlessSet( + indexes.artistlessSetsByNameLower.get(name.toLowerCase()) ?? [], + matchContext, + state.matchedSetIds, + ) + : findMatchingSet( + indexes.setsByArtistKey.get(artistKey(artistSlugs)) ?? [], + matchContext, + state.matchedSetIds, + ); const payload: SetPayload = { - name: row.setName?.trim() || row.artists.join(" b2b "), + name, + setType: row.setType ?? null, description: row.description ?? null, stageName: resolvedStage.name, timeStart, @@ -59,7 +68,11 @@ export function computeDiff( if (matched) { state.matchedSetIds.add(matched.id); - state.setsToUpdate.push({ id: matched.id, ...payload }); + state.setsToUpdate.push({ + id: matched.id, + previousSetType: asSetType(matched.set_type), + ...payload, + }); } else { state.setsToCreate.push(payload); } @@ -103,7 +116,7 @@ type DiffState = { stagesToCreate: { name: string }[]; stageNameMismatches: DiffResult["conflicts"]["stageNameMismatches"]; setsToCreate: SetPayload[]; - setsToUpdate: ({ id: string } & SetPayload)[]; + setsToUpdate: DiffResult["cleanOperations"]["setsToUpdate"]; }; function createState(): DiffState { diff --git a/supabase/functions/diff-schedule/fixtures.ts b/supabase/functions/diff-schedule/fixtures.ts new file mode 100644 index 000000000..d38e266ed --- /dev/null +++ b/supabase/functions/diff-schedule/fixtures.ts @@ -0,0 +1,29 @@ +import type { DbArtist, DbSet, DbStage } from "./types.ts"; + +export function makeArtist(name: string): DbArtist { + const slug = name.toLowerCase().replace(/\s+/g, "-"); + return { id: `id-${slug}`, name, slug }; +} + +export function makeStage(id: string, name: string): DbStage { + return { id, name }; +} + +export function makeSet( + id: string, + name: string, + artists: DbArtist[], + stageId: string | null = null, + timeStart: string | null = null, +): DbSet { + return { + id, + name, + description: null, + set_type: null, + stage_id: stageId, + time_start: timeStart, + time_end: null, + set_artists: artists.map((a) => ({ artist_id: a.id, artists: a })), + }; +} diff --git a/supabase/functions/diff-schedule/index.ts b/supabase/functions/diff-schedule/index.ts index ccaf72126..0ff15ff2d 100644 --- a/supabase/functions/diff-schedule/index.ts +++ b/supabase/functions/diff-schedule/index.ts @@ -2,6 +2,7 @@ import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; import { requireAdmin } from "../_shared/auth.ts"; import { buildCorsHeaders } from "../_shared/cors.ts"; +import { SET_TYPES } from "../_shared/setTypes.ts"; import { computeDiff } from "./computeDiff.ts"; function isValidTimezone(tz: string): boolean { @@ -26,24 +27,32 @@ function dedupeArtists(names: string[]): string[] { }); } -const csvRowSchema = z.object({ - artists: z.array(z.string().trim().min(1)).min(1).transform(dedupeArtists), - setName: z.string().optional(), - stage: z.string().optional(), - date: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD") - .optional(), - startTime: z - .string() - .regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM") - .optional(), - endTime: z - .string() - .regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM") - .optional(), - description: z.string().optional(), -}); +const csvRowSchema = z + .object({ + artists: z.array(z.string().trim().min(1)).transform(dedupeArtists), + setType: z + .enum(SET_TYPES) + .nullish() + .transform((v) => v ?? null), + setName: z.string().optional(), + stage: z.string().optional(), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD") + .optional(), + startTime: z + .string() + .regex(/^\d{2}:\d{2}$/, "startTime must be HH:MM") + .optional(), + endTime: z + .string() + .regex(/^\d{2}:\d{2}$/, "endTime must be HH:MM") + .optional(), + description: z.string().optional(), + }) + .refine((row) => row.artists.length > 0 || row.setName?.trim(), { + message: "A row without artists must have a set name", + }); const diffRequestSchema = z.object({ festivalEditionId: z.string().uuid(), @@ -96,7 +105,7 @@ serve(async (req) => { db .from("sets") .select( - "id, name, description, stage_id, time_start, time_end, set_artists(artist_id, artists(id, name, slug))", + "id, name, description, stage_id, time_start, time_end, set_type, set_artists(artist_id, artists(id, name, slug))", ) .eq("festival_edition_id", festivalEditionId) .eq("archived", false) diff --git a/supabase/functions/diff-schedule/resolvers.test.ts b/supabase/functions/diff-schedule/resolvers.test.ts index a52ed5332..5196021de 100644 --- a/supabase/functions/diff-schedule/resolvers.test.ts +++ b/supabase/functions/diff-schedule/resolvers.test.ts @@ -3,6 +3,7 @@ import { buildIndexes, computeTimes, findMatchingSet, + type MatchContext, resolveArtists, resolveStage, } from "./resolvers.ts"; @@ -79,24 +80,36 @@ Deno.test("computeTimes returns nulls when date is missing", () => { }); }); +function makeContext( + stageId: string | null = null, + date: string | undefined = undefined, +): MatchContext { + return { + stage: + stageId === null + ? { kind: "none" } + : { kind: "exact", id: stageId, name: stageId }, + date, + timezone: "UTC", + name: "", + }; +} + Deno.test("findMatchingSet returns the only available candidate", () => { const set = makeSet("set-1", []); - assertEquals(findMatchingSet([set], null, undefined, "UTC", new Set()), set); + assertEquals(findMatchingSet([set], makeContext(), new Set()), set); }); Deno.test("findMatchingSet skips already-matched candidates", () => { const set = makeSet("set-1", []); - assertEquals( - findMatchingSet([set], null, undefined, "UTC", new Set(["set-1"])), - null, - ); + assertEquals(findMatchingSet([set], makeContext(), new Set(["set-1"])), null); }); Deno.test("findMatchingSet disambiguates by stage id", () => { const a = makeSet("set-a", [], "s1"); const b = makeSet("set-b", [], "s2"); assertEquals( - findMatchingSet([a, b], "s2", undefined, "UTC", new Set())?.id, + findMatchingSet([a, b], makeContext("s2"), new Set())?.id, "set-b", ); }); @@ -105,7 +118,7 @@ Deno.test("findMatchingSet disambiguates by date", () => { const a = makeSet("set-a", [], null, "2026-07-11T20:00:00.000Z"); const b = makeSet("set-b", [], null, "2026-07-12T20:00:00.000Z"); assertEquals( - findMatchingSet([a, b], null, "2026-07-12", "UTC", new Set())?.id, + findMatchingSet([a, b], makeContext(null, "2026-07-12"), new Set())?.id, "set-b", ); }); @@ -129,6 +142,7 @@ function makeSet( id, name: id, description: null, + set_type: null, stage_id: stageId, time_start: timeStart, time_end: null, diff --git a/supabase/functions/diff-schedule/resolvers.ts b/supabase/functions/diff-schedule/resolvers.ts index 8dfea71de..abbc28e0a 100644 --- a/supabase/functions/diff-schedule/resolvers.ts +++ b/supabase/functions/diff-schedule/resolvers.ts @@ -12,6 +12,7 @@ export type DbIndexes = { stageById: Map; existingArtistSlugs: Set; setsByArtistKey: Map; + artistlessSetsByNameLower: Map; }; export function buildIndexes( @@ -19,8 +20,19 @@ export function buildIndexes( dbSets: DbSet[], dbArtists: DbArtist[], ): DbIndexes { + // Artist-less sets are matched by name (+ date/stage) instead of by roster, + // so they get their own index and stay out of the artist-key one — a roster + // row must never match a 0-artist set and vice versa. const setsByArtistKey = new Map(); + const artistlessSetsByNameLower = new Map(); for (const set of dbSets) { + if (set.set_artists.length === 0) { + const key = set.name.trim().toLowerCase(); + const bucket = artistlessSetsByNameLower.get(key) ?? []; + bucket.push(set); + artistlessSetsByNameLower.set(key, bucket); + continue; + } const slugs = set.set_artists.map((sa) => sa.artists.slug); const key = artistKey(slugs); const bucket = setsByArtistKey.get(key) ?? []; @@ -32,6 +44,7 @@ export function buildIndexes( stageById: new Map(dbStages.map((s) => [s.id, s])), existingArtistSlugs: new Set(dbArtists.map((a) => a.slug)), setsByArtistKey, + artistlessSetsByNameLower, }; } @@ -107,28 +120,111 @@ export function computeTimes( return { timeStart, timeEnd }; } +/** The CSV row's discriminators, as both matching functions consume them. */ +export type MatchContext = { + stage: StageResolution; + date: string | undefined; + timezone: string; + name: string; +}; + +/** + * Picks which existing set a roster row refers to, or null when the row is + * new. The roster is the identity, so a stage/date/name difference never + * rejects a match (it's just an update) — those fields only break ties + * between sets sharing the same roster. + */ export function findMatchingSet( candidates: DbSet[], - resolvedStageId: string | null, - date: string | undefined, - timezone: string, + context: MatchContext, alreadyMatched: Set, ): DbSet | null { - const available = candidates.filter((s) => !alreadyMatched.has(s.id)); - if (available.length <= 1) return available[0] ?? null; + const stageId = context.stage.kind === "exact" ? context.stage.id : null; + const pool = candidates.filter((s) => !alreadyMatched.has(s.id)); + return narrowByDiscriminators(pool, stageId, context); +} + +/** + * Picks which existing 0-artist set an artist-less row refers to, or null + * when the row is new. The name is the only identity, so a supplied stage + * or date must actually hold: a candidate whose stored value contradicts + * the row is excluded (the row becomes a create), while candidates with no + * stored stage/time still match — otherwise re-importing a time-less row + * would duplicate it on every run. + */ +export function findMatchingArtistlessSet( + candidates: DbSet[], + context: MatchContext, + alreadyMatched: Set, +): DbSet | null { + const stageSupplied = context.stage.kind !== "none"; + const stageId = provisionalStageId(context.stage); + const pool = candidates.filter((s) => { + if (alreadyMatched.has(s.id)) return false; + if (stageSupplied && s.stage_id != null && s.stage_id !== stageId) + return false; + if ( + context.date && + s.time_start != null && + utcToLocalDate(s.time_start, context.timezone) !== context.date + ) + return false; + return true; + }); + return narrowByDiscriminators(pool, stageId, context); +} + +/** + * The stage id to compare candidates against before the user has resolved + * the row's stage: a mismatch stands in with its closest DB stage, a + * new/absent stage pins no stage. Known limitation (#447): if the user + * later maps a mismatch to a different stage, the set was already chosen + * with this guess and the commit only rewrites stageName. + */ +function provisionalStageId(stage: StageResolution): string | null { + switch (stage.kind) { + case "exact": + return stage.id; + case "mismatch": + return stage.closest.id; + default: + return null; + } +} + +/** + * Picks the one candidate the row's discriminators point at, trusting + * stage over date over set name (the most volatile column). A discriminator + * no candidate satisfies is skipped rather than emptying the pool, so a + * partially matching CSV row still falls back to the closest candidate. + */ +function narrowByDiscriminators( + candidates: DbSet[], + resolvedStageId: string | null, + { date, timezone, name }: Pick, +): DbSet | null { + let pool = candidates; + if (pool.length <= 1) return pool[0] ?? null; if (resolvedStageId) { - const byStage = available.find((s) => s.stage_id === resolvedStageId); - if (byStage) return byStage; + const byStage = pool.filter((s) => s.stage_id === resolvedStageId); + if (byStage.length > 0) pool = byStage; } if (date) { - const byDate = available.find( + const byDate = pool.filter( (s) => s.time_start != null && utcToLocalDate(s.time_start, timezone) === date, ); - if (byDate) return byDate; + if (byDate.length > 0) pool = byDate; + } + if (name && pool.length > 1) { + const nameLower = name.trim().toLowerCase(); + const byName = pool.filter( + (s) => s.name.trim().toLowerCase() === nameLower, + ); + if (byName.length > 0) pool = byName; } - return available[0]; + return pool[0]; } function strip(s: string): string { diff --git a/supabase/functions/diff-schedule/types.ts b/supabase/functions/diff-schedule/types.ts index 9aa657f34..09a32b96e 100644 --- a/supabase/functions/diff-schedule/types.ts +++ b/supabase/functions/diff-schedule/types.ts @@ -1,7 +1,10 @@ import type { Database } from "../_shared/database.types.ts"; +import type { SetType } from "../_shared/setTypes.ts"; + export type CsvRow = { artists: string[]; + setType?: SetType | null; setName?: string; stage?: string; date?: string; @@ -21,13 +24,20 @@ export type DbStage = Pick; export type DbArtist = Pick; export type DbSet = Pick< SetRow, - "id" | "name" | "description" | "stage_id" | "time_start" | "time_end" + | "id" + | "name" + | "description" + | "stage_id" + | "time_start" + | "time_end" + | "set_type" > & { set_artists: { artist_id: string; artists: DbArtist }[]; }; export type SetPayload = { name: string; + setType: SetType | null; description: string | null; stageName: string | null; timeStart: string | null; @@ -48,7 +58,10 @@ export type DiffResult = { artistsToCreate: { name: string; slug: string }[]; stagesToCreate: { name: string }[]; setsToCreate: SetPayload[]; - setsToUpdate: ({ id: string } & SetPayload)[]; + setsToUpdate: ({ + id: string; + previousSetType: SetType | null; + } & SetPayload)[]; }; conflicts: { stageNameMismatches: { diff --git a/supabase/migrations/20260827210000_commit_schedule_set_type.sql b/supabase/migrations/20260827210000_commit_schedule_set_type.sql new file mode 100644 index 000000000..f7d51d794 --- /dev/null +++ b/supabase/migrations/20260827210000_commit_schedule_set_type.sql @@ -0,0 +1,183 @@ +-- Schedule import learns about set types and artist-less sets (#433): +-- * create/update write sets.set_type from the payload's setType key. On +-- update an explicit type overwrites while a blank/absent one preserves the +-- stored type, mirroring how stage/time columns already behave. +-- * an explicit empty artistSlugs array is a valid roster (workshops and +-- other artist-less sets); a missing or non-array roster still raises. + +CREATE OR REPLACE FUNCTION public.commit_schedule__sync_set_artists( + p_set_id UUID, + p_festival_edition_id UUID, + p_artist_slugs JSONB +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_input_count INT; + v_resolved_count INT; +BEGIN + -- A NULL or non-array roster means a bad payload (omitted field, manual + -- call) — bail before the DELETE below silently strips the set's roster. + -- An explicit empty array is intentional: an artist-less set. + IF p_artist_slugs IS NULL + OR jsonb_typeof(p_artist_slugs) <> 'array' THEN + RAISE EXCEPTION 'Missing artist roster in payload for set %', p_set_id; + END IF; + + -- Validate that every distinct input slug resolves to an artist before we + -- delete the existing links. The diff path is supposed to create missing + -- artists in step 1 of commit_schedule, so a mismatch means a bad payload + -- (typo, race, manual call) — bail loudly rather than silently producing + -- a set with a partial roster. + SELECT COUNT(DISTINCT slug_val) + INTO v_input_count + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val; + + SELECT COUNT(DISTINCT a.id) + INTO v_resolved_count + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val + JOIN artists a ON a.slug = slug_val; + + IF v_resolved_count <> v_input_count THEN + RAISE EXCEPTION + 'Unknown artist slug(s) in payload for set % (got % distinct slugs, resolved %)', + p_set_id, v_input_count, v_resolved_count; + END IF; + + -- Edition-scoped delete defends against a forged set id even if the caller + -- already verified it. + DELETE FROM set_artists sa + USING sets s + WHERE sa.set_id = s.id + AND s.id = p_set_id + AND s.festival_edition_id = p_festival_edition_id; + + INSERT INTO set_artists (set_id, artist_id) + SELECT p_set_id, a.id + FROM jsonb_array_elements_text(p_artist_slugs) AS slug_val + JOIN artists a ON a.slug = slug_val + ON CONFLICT (set_id, artist_id) DO NOTHING; +END; +$$; + +-- Update existing sets from the payload, re-syncing each set's artist roster. +-- Raises if a payload id doesn't match a set in the edition. Returns the +-- number of sets updated. +-- +-- stage_id/time_start/time_end/set_type are preserved when the payload omits +-- them (resolves to NULL): a CSV without those columns corrects names and +-- rosters without wiping metadata already on the matched sets. +CREATE OR REPLACE FUNCTION public.commit_schedule__update_sets( + p_festival_edition_id UUID, + p_sets_to_update JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_set_id UUID; + v_row_count INT; + v_updated INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_update, '[]'::jsonb)) + LOOP + v_set_id := (v_set_elem->>'id')::UUID; + + UPDATE sets + SET + name = v_set_elem->>'name', + description = NULLIF(v_set_elem->>'description', ''), + set_type = COALESCE(NULLIF(v_set_elem->>'setType', ''), sets.set_type), + stage_id = COALESCE( + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + sets.stage_id + ), + time_start = COALESCE( + commit_schedule__parse_ts(v_set_elem->>'timeStart'), sets.time_start + ), + time_end = COALESCE( + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), sets.time_end + ), + updated_at = NOW() + WHERE id = v_set_id + AND festival_edition_id = p_festival_edition_id; + + GET DIAGNOSTICS v_row_count = ROW_COUNT; + + IF v_row_count = 0 THEN + RAISE EXCEPTION 'Set % not found in edition %', v_set_id, p_festival_edition_id; + END IF; + + v_updated := v_updated + v_row_count; + + PERFORM commit_schedule__sync_set_artists( + v_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_updated; +END; +$$; + +-- Insert new sets from the payload and sync each set's artist roster. +-- Returns the number of sets created. +CREATE OR REPLACE FUNCTION public.commit_schedule__create_sets( + p_festival_edition_id UUID, + p_user_id UUID, + p_sets_to_create JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_new_set_id UUID; + v_created INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_create, '[]'::jsonb)) + LOOP + INSERT INTO sets ( + festival_edition_id, name, slug, description, set_type, stage_id, + time_start, time_end, created_by + ) + VALUES ( + p_festival_edition_id, + v_set_elem->>'name', + public.slugify(v_set_elem->>'name'), + NULLIF(v_set_elem->>'description', ''), + NULLIF(v_set_elem->>'setType', ''), + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + commit_schedule__parse_ts(v_set_elem->>'timeStart'), + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), + p_user_id + ) + RETURNING id INTO v_new_set_id; + + -- Always suffix the slug with a short id chunk so two sets with the same + -- name (common when an artist plays multiple days) don't collide on the + -- (edition, slug) lookup used by the set detail pages. + UPDATE sets + SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8) + WHERE id = v_new_set_id; + + v_created := v_created + 1; + + PERFORM commit_schedule__sync_set_artists( + v_new_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_created; +END; +$$;