From 2cd63c49fc8c6d13134e2f4de0c1911477f98218 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:16:35 +0000 Subject: [PATCH 01/28] Enforce cross-entity name uniqueness for listings and groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a name-registry that treats listing and group display names as one namespace: a listing may not share a name with another listing or with a group, and vice versa. Names are the stable key the coming catalog import/export will reference, so uniqueness is what keeps that resolution unambiguous. - New src/shared/db/name-registry.ts: cached, decrypt-in-memory name index (no schema column needed) providing isNameTakenAnywhere plus name→id resolution (matchName) with missing/ambiguous outcomes. - Wire the check into validateListingInput and validateGroupWithPackage (covering HTML forms and the admin JSON API for both entities), and add the missing validate to the group create resource so creation is guarded too. - New error.name_in_use message. - Tests for the registry and both validators; update the old "duplicate slug auto-uniquifies" listing test to assert the new name-uniqueness rejection. - Drive-by: fix a pre-existing noUselessFragments lint error in entity-pages.tsx that blocks lint:ci. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/groups.ts | 14 ++- src/locales/en/errors.json | 1 + src/shared/db/name-registry.ts | 109 +++++++++++++++++++++++ src/shared/listings-actions.ts | 8 ++ src/ui/templates/admin/entity-pages.tsx | 11 ++- test/lib/listings-actions.test.ts | 51 ++++++++++- test/lib/name-registry.test.ts | 111 ++++++++++++++++++++++++ test/lib/server-groups.test.ts | 39 +++++++++ test/lib/server-listings.test.ts | 13 +-- 9 files changed, 343 insertions(+), 14 deletions(-) create mode 100644 src/shared/db/name-registry.ts create mode 100644 test/lib/name-registry.test.ts diff --git a/src/features/admin/groups.ts b/src/features/admin/groups.ts index c54f18488a..ecb1435553 100644 --- a/src/features/admin/groups.ts +++ b/src/features/admin/groups.ts @@ -41,6 +41,7 @@ import { getActiveHolidays } from "#shared/db/holidays.ts"; import { edgeIdsTouchingMany } from "#shared/db/listing-parents.ts"; import { getGroupDayPrices } from "#shared/db/listing-prices.ts"; import { getAttendeesByListingIds, getListing } from "#shared/db/listings.ts"; +import { isNameTakenAnywhere } from "#shared/db/name-registry.ts"; import { loadAttendeeQuestionData } from "#shared/db/questions.ts"; import { settings } from "#shared/db/settings.ts"; import { clearItemEdgesStatement } from "#shared/db/site-page-items.ts"; @@ -178,6 +179,13 @@ export const soldHiddenPackageError = async ( * per-member cards. A HIDDEN sold package must not take that fall-back path * ({@link soldHiddenPackageError}). */ export const validateGroupWithPackage: GroupValidator = async (input, id) => { + // A group name must be unique across BOTH groups and listings (create and edit + // alike), mirroring the listing-side check so the two share one namespace. + const nameTaken = await isNameTakenAnywhere( + input.name, + id === undefined ? undefined : { id: Number(id), kind: "group" }, + ); + if (nameTaken) return t("error.name_in_use"); const slugError = await validateGroupSlug(input, id); if (slugError) return slugError; if (id === undefined) return null; @@ -326,13 +334,17 @@ const crudConfig = { singular: "Group", } as const; -/** Groups resource for REST create operations (auto-generated slug) */ +/** Groups resource for REST create operations (auto-generated slug). Validates + * with {@link validateGroupWithPackage} so a new group's name uniqueness is + * enforced on create too; the package checks it runs are no-ops on create (the + * group has no members yet) and the auto-generated slug is already unique. */ const groupsCreateResource = defineNamedResource({ fields: getGroupCreateFields(), nameField: "name", onDelete: deleteGroup, table: groupsTable, toInput: extractGroupCreateInput, + validate: validateGroupWithPackage, }); /** Persist the group's per-listing package overrides (price + quantity) after diff --git a/src/locales/en/errors.json b/src/locales/en/errors.json index 2950840ede..4db6a102b2 100644 --- a/src/locales/en/errors.json +++ b/src/locales/en/errors.json @@ -37,6 +37,7 @@ "error.listing_name_mismatch": "Listing name does not match. Please type the exact name to confirm.", "error.listing_name_mismatch_delete": "Listing name does not match. Please type the exact name to confirm deletion.", "error.slug_in_use": "Slug is already in use by another listing", + "error.name_in_use": "Name is already in use by another listing or group", "error.not_enough_spots": "Not enough spots available", "error.encryption_error": "Encryption error — check that DB_ENCRYPTION_KEY is configured", "error.no_payment_to_refund": "This attendee has no payment to refund.", diff --git a/src/shared/db/name-registry.ts b/src/shared/db/name-registry.ts new file mode 100644 index 0000000000..28b4e0a6fb --- /dev/null +++ b/src/shared/db/name-registry.ts @@ -0,0 +1,109 @@ +/** + * Cross-entity display-name uniqueness and name→id resolution for listings and + * groups. + * + * A listing or group name must be unique across **both** tables: a listing may + * not share a name with another listing *or* with a group, and vice versa. This + * is what lets the catalog import/export feature reference listings and groups + * by name alone (names are stable across installs; ids are not). + * + * Names are field-level encrypted with no blind index, so — unlike the slug + * registry, which matches on the plaintext `slug_index` HMAC — this reads the + * cached, already-decrypted listing/group sets (`getAllListings`/`getAllGroups`, + * the very loads the admin collection pages already make) and matches in memory. + * The catalog is a bounded, admin-scale set, so the scan is cheap and needs no + * extra query or schema column. + * + * The module is a thin shell over those two cached reads; comparison and lookup + * are pure functions over the loaded rows. + */ + +import { getAllGroups } from "#shared/db/groups.ts"; +import { getAllListings } from "#shared/db/listings.ts"; + +/** The two entity kinds that share the catalog name namespace. */ +export type NamedEntityKind = "listing" | "group"; + +/** One entity that owns a name — used to exclude the row being edited from its + * own uniqueness check. */ +export type NameOwner = { kind: NamedEntityKind; id: number }; + +/** + * Normalise a display name for uniqueness comparison and name-keyed lookup: + * trimmed and case-folded. Two names that differ only in surrounding whitespace + * or letter case are treated as the same name, so an import that resolves a + * parent/group by name is never ambiguous between "Weekend" and "weekend ". + */ +export const normalizeEntityName = (name: string): string => + name.trim().toLowerCase(); + +/** Normalised-name → ids for one entity kind. A name maps to several ids only on + * legacy data that predates the uniqueness rule (new writes can't create one). */ +export type NameIndex = Map; + +/** Build a {@link NameIndex} from decrypted `{id, name}` rows, dropping empty + * names (an unnamed row never participates in uniqueness or name lookup). */ +const buildIndex = ( + entities: ReadonlyArray<{ id: number; name: string }>, +): NameIndex => { + const index: NameIndex = new Map(); + for (const entity of entities) { + const key = normalizeEntityName(entity.name); + if (key === "") continue; + const ids = index.get(key); + if (ids) ids.push(entity.id); + else index.set(key, [entity.id]); + } + return index; +}; + +/** Both entity kinds' name indexes, loaded once so an import can resolve every + * parent/group/member reference against a single snapshot. */ +export type CatalogNameIndex = { + listing: NameIndex; + group: NameIndex; +}; + +/** Load the listing and group name indexes from the (cached) decrypted catalog. */ +export const loadCatalogNameIndex = async (): Promise => { + const [listings, groups] = await Promise.all([ + getAllListings(), + getAllGroups(), + ]); + return { group: buildIndex(groups), listing: buildIndex(listings) }; +}; + +/** The result of resolving a single name against a {@link NameIndex}: the unique + * id, or the reason it could not be resolved. `ambiguous` only occurs on legacy + * duplicate-named data; both reasons yield an intelligible import error. */ +export type NameMatch = + | { ok: true; id: number } + | { ok: false; reason: "missing" | "ambiguous" }; + +/** Resolve one name to its unique owning id within a single entity kind. */ +export const matchName = (index: NameIndex, name: string): NameMatch => { + const ids = index.get(normalizeEntityName(name)) ?? []; + if (ids.length === 0) return { ok: false, reason: "missing" }; + if (ids.length > 1) return { ok: false, reason: "ambiguous" }; + return { id: ids[0]!, ok: true }; +}; + +/** + * Is `name` already used by any listing or group? `exclude` skips one row (the + * entity being edited) so it keeps its own name. An empty/whitespace name is + * never "taken" — the required-field validation handles blank names, and an + * unnamed legacy row must not block every new save. + */ +export const isNameTakenAnywhere = async ( + name: string, + exclude?: NameOwner, +): Promise => { + const key = normalizeEntityName(name); + if (key === "") return false; + const { group, listing } = await loadCatalogNameIndex(); + const ownedByOther = (index: NameIndex, kind: NamedEntityKind): boolean => + (index.get(key) ?? []).some( + (id) => !(exclude && exclude.kind === kind && exclude.id === id), + ); + return ownedByOther(listing, "listing") || ownedByOther(group, "group"); +}; diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index 93ef832d2d..d1a0680222 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -34,6 +34,7 @@ import { type ListingGroupMembership, toListingGroupMembership, } from "#shared/db/modifier-resolve.ts"; +import { isNameTakenAnywhere } from "#shared/db/name-registry.ts"; import type { EdgeListing } from "#shared/listing-parents-rules.ts"; import { generateUniqueSlug } from "#shared/slug.ts"; import { deleteListingStorageFiles } from "#shared/storage.ts"; @@ -294,6 +295,13 @@ export const validateListingInput = async ( input: ListingInput, existingId?: number, ): Promise => { + // A listing name must be unique across BOTH listings and groups (create and + // edit alike), so the catalog can be referenced by name for import/export. + const nameTaken = await isNameTakenAnywhere( + input.name, + existingId === undefined ? undefined : { id: existingId, kind: "listing" }, + ); + if (nameTaken) return t("error.name_in_use"); if (existingId !== undefined) { const taken = await isSlugTaken(input.slug, existingId); if (taken) return t("error.slug_in_use"); diff --git a/src/ui/templates/admin/entity-pages.tsx b/src/ui/templates/admin/entity-pages.tsx index 33bd48d52f..085ee70383 100644 --- a/src/ui/templates/admin/entity-pages.tsx +++ b/src/ui/templates/admin/entity-pages.tsx @@ -63,8 +63,9 @@ export type LoadedSection = } | { kind: "custom"; html: JSX.Element | null }; -/** A summary value, linked when the row carries an href. */ -const SummaryValue = ({ row }: { row: SummaryRow }): JSX.Element => +/** A summary value, linked when the row carries an href (plain text otherwise — + * a bare string is a valid JSX child, so no wrapping fragment is needed). */ +const summaryValue = (row: SummaryRow): JSX.Element | string => row.href ? ( {row.value} ) : ( - <>{row.value} + row.value ); /** The read-only key/value summary table. */ @@ -88,9 +89,7 @@ const SummarySection = ({ {section.rows.map((row) => ( {t(row.labelKey)} - - - + {summaryValue(row)} ))} diff --git a/test/lib/listings-actions.test.ts b/test/lib/listings-actions.test.ts index 89b695d32e..0f7022ed7c 100644 --- a/test/lib/listings-actions.test.ts +++ b/test/lib/listings-actions.test.ts @@ -5,7 +5,13 @@ import { listingInputToEdge, validateListingInput, } from "#shared/listings-actions.ts"; -import { setupTestEncryptionKey, testListingInput } from "#test-utils"; +import { + createTestGroup, + createTestListing, + describeWithEnv, + setupTestEncryptionKey, + testListingInput, +} from "#test-utils"; setupTestEncryptionKey(); @@ -44,7 +50,11 @@ describe("listingInputToEdge", () => { }); }); -describe("validateListingInput", () => { +// validateListingInput now reads the catalog (for cross-entity name +// uniqueness), so these cases run against an empty test DB — no listing/group +// shares these names, so the uniqueness check passes and each case exercises +// the specific rule it names. +describeWithEnv("validateListingInput", { db: true }, () => { test("rejects assignBuiltSite with initialSiteMonths <= 0", async () => { const input: ListingInput = { ...testListingInput({ @@ -173,4 +183,41 @@ describe("validateListingInput", () => { }); await expect(validateListingInput(input)).resolves.toBeNull(); }); + + const NAME_IN_USE = "Name is already in use by another listing or group"; + + const namedInput = (name: string): ListingInput => ({ + ...testListingInput({ name }), + slug: "some-slug", + slugIndex: "some-index", + }); + + test("rejects a create whose name is used by an existing listing", async () => { + await createTestListing({ name: "Taken Name" }); + await expect(validateListingInput(namedInput("Taken Name"))).resolves.toBe( + NAME_IN_USE, + ); + }); + + test("rejects a create whose name is used by a group", async () => { + await createTestGroup({ name: "Group Name" }); + await expect(validateListingInput(namedInput("Group Name"))).resolves.toBe( + NAME_IN_USE, + ); + }); + + test("lets a listing keep its own name on edit", async () => { + const listing = await createTestListing({ name: "Mine" }); + await expect( + validateListingInput(namedInput("Mine"), listing.id), + ).resolves.toBeNull(); + }); + + test("rejects renaming a listing to another listing's name", async () => { + const first = await createTestListing({ name: "First" }); + const second = await createTestListing({ name: "Second" }); + await expect( + validateListingInput(namedInput(first.name), second.id), + ).resolves.toBe(NAME_IN_USE); + }); }); diff --git a/test/lib/name-registry.test.ts b/test/lib/name-registry.test.ts new file mode 100644 index 0000000000..5d42f98111 --- /dev/null +++ b/test/lib/name-registry.test.ts @@ -0,0 +1,111 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { computeSlugIndex, listingsTable } from "#shared/db/listings.ts"; +import { + isNameTakenAnywhere, + loadCatalogNameIndex, + matchName, + normalizeEntityName, +} from "#shared/db/name-registry.ts"; +import { + createTestGroup, + createTestListing, + describeWithEnv, +} from "#test-utils"; + +describeWithEnv("name-registry", { db: true }, () => { + test("normalizeEntityName trims and case-folds", () => { + expect(normalizeEntityName(" Weekend Pass ")).toBe("weekend pass"); + expect(normalizeEntityName("WEEKEND pass")).toBe("weekend pass"); + }); + + test("a name is free on an empty catalog", async () => { + expect(await isNameTakenAnywhere("Anything")).toBe(false); + }); + + test("a listing name is taken by that listing", async () => { + await createTestListing({ name: "Solo Show" }); + expect(await isNameTakenAnywhere("Solo Show")).toBe(true); + // Case- and whitespace-insensitive, so an import can't smuggle a near-dup. + expect(await isNameTakenAnywhere(" solo show ")).toBe(true); + }); + + test("a listing name collides with a group of the same name", async () => { + await createTestGroup({ name: "Shared Name" }); + // A LISTING may not take a GROUP's name — the two share one namespace. + expect(await isNameTakenAnywhere("Shared Name")).toBe(true); + }); + + test("a group name collides with a listing of the same name", async () => { + await createTestListing({ name: "Overlap" }); + expect( + await isNameTakenAnywhere("Overlap", { id: 99999, kind: "group" }), + ).toBe(true); + }); + + test("excluding the owning row lets it keep its own name", async () => { + const listing = await createTestListing({ name: "Keep Me" }); + expect( + await isNameTakenAnywhere("Keep Me", { id: listing.id, kind: "listing" }), + ).toBe(false); + // Excluding the wrong kind/id does not free the name. + expect( + await isNameTakenAnywhere("Keep Me", { id: listing.id, kind: "group" }), + ).toBe(true); + }); + + test("a blank or whitespace-only name is never taken", async () => { + expect(await isNameTakenAnywhere("")).toBe(false); + expect(await isNameTakenAnywhere(" ")).toBe(false); + }); + + test("matchName resolves a unique listing and group by name", async () => { + const listing = await createTestListing({ name: "Findable" }); + const group = await createTestGroup({ name: "Group One" }); + const index = await loadCatalogNameIndex(); + expect(matchName(index.listing, "findable")).toEqual({ + id: listing.id, + ok: true, + }); + expect(matchName(index.group, " Group One ")).toEqual({ + id: group.id, + ok: true, + }); + }); + + test("matchName reports a missing name", async () => { + const index = await loadCatalogNameIndex(); + expect(matchName(index.listing, "Ghost")).toEqual({ + ok: false, + reason: "missing", + }); + }); + + test("matchName reports an ambiguous legacy duplicate", async () => { + // Insert straight through the table (bypassing the uniqueness validator) to + // simulate legacy data that predates the rule: two listings, one name. + const slugA = "dup-a"; + const slugB = "dup-b"; + await listingsTable.insert({ + maxAttendees: 1, + maxPrice: 0, + name: "Twin", + slug: slugA, + slugIndex: await computeSlugIndex(slugA), + }); + await listingsTable.insert({ + maxAttendees: 1, + maxPrice: 0, + name: "Twin", + slug: slugB, + slugIndex: await computeSlugIndex(slugB), + }); + const index = await loadCatalogNameIndex(); + expect(matchName(index.listing, "Twin")).toEqual({ + ok: false, + reason: "ambiguous", + }); + // And the ambiguous name reads as taken for uniqueness purposes. + expect(await isNameTakenAnywhere("Twin")).toBe(true); + }); +}); diff --git a/test/lib/server-groups.test.ts b/test/lib/server-groups.test.ts index a0fa05417a..29dd4b2220 100644 --- a/test/lib/server-groups.test.ts +++ b/test/lib/server-groups.test.ts @@ -146,6 +146,45 @@ describeWithEnv("server (admin groups)", { db: true }, () => { expect(group.description).toBe("A fun group of listings"); }); + const NAME_IN_USE = "Name is already in use by another listing or group"; + + test("rejects a group whose name is used by a listing", async () => { + await createTestListing({ name: "Clash Name" }); + const { response } = await adminFormPost("/admin/groups", { + name: "Clash Name", + terms_and_conditions: "", + }); + await expectFlashRedirect( + "/admin/groups/new", + NAME_IN_USE, + false, + )(response); + }); + + test("rejects a group whose name is used by another group", async () => { + await createTestGroup({ name: "Twin Group" }); + const { response } = await adminFormPost("/admin/groups", { + name: "Twin Group", + terms_and_conditions: "", + }); + await expectFlashRedirect( + "/admin/groups/new", + NAME_IN_USE, + false, + )(response); + }); + + test("lets a group keep its own name on edit", async () => { + const group = await createTestGroup({ name: "Renamer" }); + // Re-saving the group under its own name must not trip the uniqueness + // check against itself. + const updated = await updateTestGroup(group.id, { + name: "Renamer", + slug: group.slug, + }); + expect(updated.name).toBe("Renamer"); + }); + test("creates group without description defaults to empty string", async () => { const group = await createTestGroup({ name: "No Desc Group" }); expect(group.description).toBe(""); diff --git a/test/lib/server-listings.test.ts b/test/lib/server-listings.test.ts index bd8d297368..be8183d917 100644 --- a/test/lib/server-listings.test.ts +++ b/test/lib/server-listings.test.ts @@ -346,23 +346,26 @@ describeWithEnv("server (admin listings)", { db: true }, () => { ); }); - test("rejects duplicate slug", async () => { - // First, create an listing with a specific name + test("rejects a duplicate listing name", async () => { + // First, create a listing with a specific name await setupListingAndLogin({ maxAttendees: 100, name: "Duplicate Listing", thankYouUrl: "https://example.com", }); - // Try to create another listing with the same name (generates same slug) + // A second listing may not reuse the name — names are unique across the + // catalog so listings/groups can be referenced by name for import/export. const { response } = await adminMultipartPost("/admin/listing", { max_attendees: "50", max_quantity: "1", name: "Duplicate Listing", thank_you_url: "https://example.com", }); - // Slug auto-generated so creation succeeds - await expectFlashRedirect("/admin", "Listing created")(response); + expect(response.status).toBe(400); + expect(await response.text()).toContain( + "Name is already in use by another listing or group", + ); }); }); From e461f3aef4735001560559dc590c38ade1111c21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:30:17 +0000 Subject: [PATCH 02/28] Add catalog transfer engine: export/import a listing or group as JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce src/features/admin/catalog-transfer with a versioned, id-free JSON format that captures a single listing or group and all its facets — prices, group memberships (with package price/quantity/day overrides), and parent references — cross-referenced by name so a blob is portable across installs. - schema.ts: valibot discriminated union (listing/group) as the single source of truth; validates an incoming blob at the boundary and types the exporter's output. formatTransferIssues renders per-field messages. - export.ts: build the blob from the decrypted stored row and its facets, resolving every reference to a name. Images/attachments, ledger, and attendees are deliberately excluded. - import.ts: parse → check name uniqueness → resolve parents/groups/ members by name (intelligible missing/ambiguous errors) → reuse the shared listing/group validators (type compatibility, package rules, parent-edge compatibility) → write in one transaction. Never throws for bad input. Supporting db helpers: getListingGroupMemberships + addGroupMembershipTx (groups), listingGroupDayInsertStatements (listing-prices, targeted so importing into a populated package can't disturb other members), addParentEdgesTx (listing-parents), and an exported allPackageableMembers. Round-trip and validation-failure tests cover both entity kinds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/export.ts | 187 +++++++++++ src/features/admin/catalog-transfer/import.ts | 310 ++++++++++++++++++ src/features/admin/catalog-transfer/schema.ts | 165 ++++++++++ src/features/admin/groups.ts | 2 +- src/shared/db/groups.ts | 51 +++ src/shared/db/listing-parents.ts | 15 + src/shared/db/listing-prices.ts | 19 ++ test/lib/catalog-transfer.test.ts | 249 ++++++++++++++ 8 files changed, 997 insertions(+), 1 deletion(-) create mode 100644 src/features/admin/catalog-transfer/export.ts create mode 100644 src/features/admin/catalog-transfer/import.ts create mode 100644 src/features/admin/catalog-transfer/schema.ts create mode 100644 test/lib/catalog-transfer.test.ts diff --git a/src/features/admin/catalog-transfer/export.ts b/src/features/admin/catalog-transfer/export.ts new file mode 100644 index 0000000000..64553dd2c5 --- /dev/null +++ b/src/features/admin/catalog-transfer/export.ts @@ -0,0 +1,187 @@ +/** + * Build the id-free JSON export for one listing or group (see schema.ts). + * + * The exporter reads the decrypted stored row and its related facets — group + * memberships (with package overrides), parent listings, and per-day package + * overrides — and renders every cross-reference by name. Prices come straight + * off the listing columns (`unit_price`/`day_prices`); the derived + * `listing_prices` mirror rows are re-synced from those on import, so they are + * not exported separately. + */ + +import * as v from "valibot"; +import { mapNotNullish } from "#fp"; +import { + getAllGroupNames, + getGroupPackagePrices, + getListingGroupMemberships, + groupsTable, +} from "#shared/db/groups.ts"; +import { getParentIds } from "#shared/db/listing-parents.ts"; +import { + getGroupDayPrices, + getGroupDayPricesByGroupIds, +} from "#shared/db/listing-prices.ts"; +import { + getListingNamesByIds, + getStoredListingWithCount, + listingsTable, +} from "#shared/db/listings.ts"; +import { + CATALOG_TRANSFER_VERSION, + GroupDataSchema, + type GroupMember, + type GroupTransfer, + ListingDataSchema, + type ListingMembership, + type ListingTransfer, +} from "./schema.ts"; + +/** Listing columns that never travel: the id/slug/timestamp columns (an import + * mints fresh ones) and the image/attachment columns (deliberately out of + * scope). Named in snake_case for {@link listingsTable.rowToInput}. */ +const LISTING_EXPORT_EXCLUDED = [ + "created", + "slug", + "slug_index", + "image_url", + "attachment_url", + "attachment_name", +] as const; + +/** Group columns that never travel — the slug pair (regenerated on import). */ +const GROUP_EXPORT_EXCLUDED = ["slug", "slug_index"] as const; + +/** Convert a per-day override map to the JSON record shape, or undefined when + * there are no overrides (so an empty map is omitted from the blob). */ +const dayPricesToRecord = ( + dayPrices: ReadonlyMap | undefined, +): Record | undefined => { + if (!dayPrices || dayPrices.size === 0) return undefined; + const record: Record = {}; + for (const [day, price] of dayPrices) record[String(day)] = price; + return record; +}; + +/** The package-override fields shared by both membership views, each omitted at + * its neutral default (no price override, quantity 1, no per-day overrides) so a + * plain membership serialises to just its name reference. */ +const overrideFields = ( + packagePrice: number | null, + quantity: number, + dayPrices: ReadonlyMap | undefined, +): { + packagePrice?: number; + quantity?: number; + dayPrices?: Record; +} => { + const record = dayPricesToRecord(dayPrices); + return { + ...(packagePrice === null ? {} : { packagePrice }), + ...(quantity === 1 ? {} : { quantity }), + ...(record ? { dayPrices: record } : {}), + }; +}; + +/** + * Build the JSON export for the listing with `id`, or null when it does not + * exist. Reads the *stored* row (no operator defaults overlaid) so a re-import + * preserves the listing's own columns. + */ +export const exportListing = async ( + id: number, +): Promise => { + const listing = await getStoredListingWithCount(id); + if (!listing) return null; + + const [memberships, groupNames, parentIds] = await Promise.all([ + getListingGroupMemberships(id), + getAllGroupNames(), + getParentIds(id), + ]); + const groupDayPrices = await getGroupDayPricesByGroupIds( + memberships.map((m) => m.group_id), + ); + + const groups: ListingMembership[] = mapNotNullish( + (m: { + group_id: number; + package_price: number | null; + quantity: number; + }) => { + const name = groupNames.get(m.group_id); + if (name === undefined) return undefined; + return { + group: name, + ...overrideFields( + m.package_price, + m.quantity, + groupDayPrices.get(m.group_id)?.get(id), + ), + }; + }, + )(memberships); + + const parentNames = await getListingNamesByIds(parentIds); + const parents = mapNotNullish((parentId: number) => + parentNames.get(parentId), + )(parentIds); + + return { + groups, + kind: "listing", + listing: v.parse( + ListingDataSchema, + listingsTable.rowToInput(listing, LISTING_EXPORT_EXCLUDED), + ), + parents, + version: CATALOG_TRANSFER_VERSION, + }; +}; + +/** + * Build the JSON export for the group with `id`, or null when it does not + * exist. Includes every member listing (by name) with its package override, + * quantity, and per-day overrides. + */ +export const exportGroup = async ( + id: number, +): Promise => { + const group = await groupsTable.findById(id); + if (!group) return null; + + const rows = await getGroupPackagePrices(id); + const [listingNames, dayPrices] = await Promise.all([ + getListingNamesByIds(rows.map((r) => r.listing_id)), + getGroupDayPrices(id), + ]); + + const members: GroupMember[] = mapNotNullish( + (row: { + listing_id: number; + package_price: number | null; + quantity: number; + }) => { + const name = listingNames.get(row.listing_id); + if (name === undefined) return undefined; + return { + listing: name, + ...overrideFields( + row.package_price, + row.quantity, + dayPrices.get(row.listing_id), + ), + }; + }, + )(rows); + + return { + group: v.parse( + GroupDataSchema, + groupsTable.rowToInput(group, GROUP_EXPORT_EXCLUDED), + ), + kind: "group", + members, + version: CATALOG_TRANSFER_VERSION, + }; +}; diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts new file mode 100644 index 0000000000..67f6ab7cd8 --- /dev/null +++ b/src/features/admin/catalog-transfer/import.ts @@ -0,0 +1,310 @@ +/** + * Validate and apply a catalog transfer blob (see schema.ts), creating one new + * listing or group with all its facets — group memberships (with package + * overrides), parent references, and per-day overrides — resolving every + * cross-reference by name. + * + * Every failure path returns an intelligible, field-level message rather than a + * raw system error: shape errors from the schema, an already-used name, an + * unresolved/ambiguous reference, or a reused business rule (group type + * compatibility, package rules, parent-edge compatibility). The write itself is + * one transaction, so a partial import can never persist. + */ + +import * as v from "valibot"; +import { mapNotNullish } from "#fp"; +import { t } from "#i18n"; +import { + allPackageableMembers, + generateUniqueGroupSlug, +} from "#routes/admin/groups.ts"; +import { writeRowInTransaction } from "#shared/db/client.ts"; +import { + addGroupMembershipTx, + type GroupInput, + groupsTable, + type ImportedMembership, +} from "#shared/db/groups.ts"; +import { addParentEdgesTx } from "#shared/db/listing-parents.ts"; +import { syncListingPrices } from "#shared/db/listing-prices.ts"; +import { + getListingsById, + getListingsWithCountsByIds, + type ListingInput, + listingsTable, +} from "#shared/db/listings.ts"; +import { + isNameTakenAnywhere, + loadCatalogNameIndex, + matchName, + type NameIndex, +} from "#shared/db/name-registry.ts"; +import { + type EdgeListing, + edgeFieldError, +} from "#shared/listing-parents-rules.ts"; +import { + generateUniqueListingSlug, + listingInputToEdge, + validateListingInput, +} from "#shared/listings-actions.ts"; +import { + type Listing, + type ListingType, + normalizeDurationDays, + parseDayPrices, +} from "#shared/types.ts"; +import { + CatalogTransferSchema, + formatTransferIssues, + type GroupTransfer, + type ListingData, + type ListingTransfer, +} from "./schema.ts"; + +/** The result of an import attempt: the created entity's kind/id/name, or an + * operator-facing error explaining what to fix. */ +export type ImportResult = + | { ok: true; kind: "listing" | "group"; id: number; name: string } + | { ok: false; error: string }; + +const fail = (error: string): ImportResult => ({ error, ok: false }); + +/** Resolve a list of names to ids within one entity kind, returning the ids or + * the first reference that can't be resolved (missing or, on legacy duplicate + * data, ambiguous) as an intelligible error. `noun` names the referenced kind + * in the message. */ +const resolveNames = ( + index: NameIndex, + names: readonly string[], + noun: string, +): { ids: number[] } | { error: string } => { + const ids: number[] = []; + for (const name of names) { + const match = matchName(index, name); + if (!match.ok) { + return { + error: + match.reason === "missing" + ? `No ${noun} named "${name}" exists — it must already exist to import this reference.` + : `More than one ${noun} is named "${name}"; names must be unique to import by name.`, + }; + } + ids.push(match.id); + } + return { ids }; +}; + +/** The uniform "this name is already taken" refusal for both entity kinds. */ +const nameTakenError = (name: string): string => + `A listing or group named "${name}" already exists — rename or remove it before importing.`; + +/** One membership to write once the new row's id is known (the listing-side and + * group-side imports differ only in which id is fixed). */ +type MembershipSpec = Omit; + +/** Read the shared package-override fields off a membership/member entry. */ +const membershipSpec = (entry: { + packagePrice?: number | null | undefined; + quantity?: number | undefined; + dayPrices?: Record | undefined; +}): MembershipSpec => ({ + dayPrices: entry.dayPrices ? parseDayPrices(entry.dayPrices) : {}, + packagePrice: entry.packagePrice ?? null, + quantity: entry.quantity ?? 1, +}); + +/** Project a validated listing blob onto a `ListingInput`, minting a fresh slug + * and clearing the (non-transferred) image/attachment columns. Optional fields + * pass through untouched so the table applies its own column defaults. */ +const listingDataToInput = ( + data: ListingData, + slug: string, + slugIndex: string, + groupIds: number[], +): ListingInput => { + const { closesAt, dayPrices, ...rest } = data; + // The cast bridges valibot's `T | undefined` optionals to the input's exact + // optionals — the same shape buildDuplicateListingInput uses for rowToInput. + return { + ...rest, + attachmentName: "", + attachmentUrl: "", + closesAt: closesAt ?? undefined, + dayPrices: dayPrices === undefined ? undefined : parseDayPrices(dayPrices), + groupIds, + imageUrl: "", + slug, + slugIndex, + } as ListingInput; +}; + +/** A listing row projected onto the edge-compatibility shape. */ +const listingToEdge = (listing: Listing): EdgeListing => ({ + customisable_days: listing.customisable_days, + day_prices: listing.day_prices, + duration_days: normalizeDurationDays(listing.duration_days), + id: listing.id, + listing_type: listing.listing_type, + months_per_unit: listing.months_per_unit, + name: listing.name, +}); + +/** Reject a would-be child that can't sit under one of its named parents: a + * package member is never folded under a parent, and each parent→child edge must + * satisfy the same field-compatibility rules the edge editor enforces. */ +const validateParentEdges = async ( + input: ListingInput, + parentIds: readonly number[], +): Promise => { + if (parentIds.length === 0) return null; + for (const groupId of input.groupIds ?? []) { + const group = await groupsTable.findById(groupId); + if (group?.is_package) { + return `"${input.name}" is a member of the package "${group.name}", so it cannot also be an add-on child of another listing.`; + } + } + const childEdge = listingInputToEdge(input, 0); + const byId = await getListingsById(); + for (const parentId of parentIds) { + const parent = byId.get(parentId); + if (!parent) continue; + const error = edgeFieldError(listingToEdge(parent), childEdge); + if (error) return error; + } + return null; +}; + +const importListing = async ( + transfer: ListingTransfer, +): Promise => { + const { groups: memberships, listing, parents } = transfer; + if (await isNameTakenAnywhere(listing.name)) { + return fail(nameTakenError(listing.name)); + } + + const index = await loadCatalogNameIndex(); + const parentResolve = resolveNames(index.listing, parents, "listing"); + if ("error" in parentResolve) return fail(parentResolve.error); + const groupResolve = resolveNames( + index.group, + memberships.map((m) => m.group), + "group", + ); + if ("error" in groupResolve) return fail(groupResolve.error); + + const { slug, slugIndex } = await generateUniqueListingSlug(); + const input = listingDataToInput(listing, slug, slugIndex, groupResolve.ids); + + const validationError = await validateListingInput(input); + if (validationError) return fail(validationError); + const edgeError = await validateParentEdges(input, parentResolve.ids); + if (edgeError) return fail(edgeError); + + const specs = memberships.map((m, i) => ({ + ...membershipSpec(m), + groupId: groupResolve.ids[i]!, + })); + const id = await writeRowInTransaction( + await listingsTable.insertStatement!(input), + null, + async (tx, newId) => { + for (const spec of specs) { + await addGroupMembershipTx(tx, { ...spec, listingId: newId }); + } + await addParentEdgesTx(tx, newId, parentResolve.ids); + }, + ); + // insertStatement bypassed the table wrapper, so re-sync the derived + // base/day_count price rows from the just-written columns (as afterCommit does). + await syncListingPrices(id); + return { id, kind: "listing", name: input.name, ok: true }; +}; + +/** Every listing in a group must share both its type and its customisable-days + * setting (so the shared booking form shows one selector). Returns the first + * mismatch as an intelligible error, or null when the members are homogeneous. */ +const membersHomogeneous = ( + listings: readonly { + name: string; + listing_type: ListingType; + customisable_days: boolean; + }[], +): string | null => { + const [first, ...rest] = listings; + if (!first) return null; + const typeMismatch = rest.find((l) => l.listing_type !== first.listing_type); + if (typeMismatch) { + return `All listings in a group must be the same type, but "${first.name}" is ${first.listing_type} and "${typeMismatch.name}" is ${typeMismatch.listing_type}.`; + } + const customMismatch = rest.find( + (l) => l.customisable_days !== first.customisable_days, + ); + if (customMismatch) { + return `All listings in a group must agree on customisable days, but "${first.name}" and "${customMismatch.name}" differ.`; + } + return null; +}; + +const importGroup = async (transfer: GroupTransfer): Promise => { + const { group, members } = transfer; + if (await isNameTakenAnywhere(group.name)) { + return fail(nameTakenError(group.name)); + } + + const index = await loadCatalogNameIndex(); + const memberResolve = resolveNames( + index.listing, + members.map((m) => m.listing), + "listing", + ); + if ("error" in memberResolve) return fail(memberResolve.error); + + const listings = await getListingsWithCountsByIds(memberResolve.ids); + const byId = new Map(listings.map((l) => [l.id, l])); + const memberListings = mapNotNullish((id: number) => byId.get(id))( + memberResolve.ids, + ); + + const homogeneityError = membersHomogeneous(memberListings); + if (homogeneityError) return fail(homogeneityError); + if ( + group.isPackage && + !(await allPackageableMembers(memberListings, group.hidePackageListings)) + ) { + return fail(t("error.package_incompatible_listing")); + } + + const { slug, slugIndex } = await generateUniqueGroupSlug(); + // Cast bridges valibot's `T | undefined` optionals to GroupInput's exact + // optionals; members are written separately (not via GroupInput.packageMembers). + const input = { ...group, slug, slugIndex } as GroupInput; + const specs = members.map((m, i) => ({ + ...membershipSpec(m), + listingId: memberResolve.ids[i]!, + })); + const id = await writeRowInTransaction( + await groupsTable.insertStatement!(input), + null, + async (tx, newId) => { + for (const spec of specs) { + await addGroupMembershipTx(tx, { ...spec, groupId: newId }); + } + }, + ); + return { id, kind: "group", name: input.name, ok: true }; +}; + +/** + * Parse, validate, and apply a catalog transfer blob. Returns the created + * entity on success, or an intelligible error describing exactly what to fix. + * Never throws for bad input — malformed JSON is rejected upstream and every + * validation failure returns `{ ok: false, error }`. + */ +export const importCatalog = async (blob: unknown): Promise => { + const parsed = v.safeParse(CatalogTransferSchema, blob); + if (!parsed.success) return fail(formatTransferIssues(parsed.issues)); + return parsed.output.kind === "listing" + ? importListing(parsed.output) + : importGroup(parsed.output); +}; diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts new file mode 100644 index 0000000000..54389f31aa --- /dev/null +++ b/src/features/admin/catalog-transfer/schema.ts @@ -0,0 +1,165 @@ +/** + * The versioned, id-free JSON wire format for exporting/importing a single + * listing or group ("catalog transfer"). + * + * A blob is a discriminated union on `kind` (`"listing"` | `"group"`). It never + * carries database ids — every cross-reference (a listing's parents and group + * memberships, a group's member listings) is by **name**, since names are stable + * across installs while ids are not. Images/attachments, ledger data, and + * attendees are deliberately excluded: a transfer describes the catalog + * structure and pricing, not the files or the money/booking history bound to one + * install. + * + * This schema is the single source of truth for the format: it validates an + * incoming blob at the import boundary (producing per-field messages via + * {@link formatTransferIssues}) and types the objects the exporter builds. + * Semantic validation beyond shape (name uniqueness, reference resolution, group + * compatibility) happens in `import.ts` on top of a successful parse. + */ + +import * as v from "valibot"; +import { ListingTypeSchema } from "#shared/types.ts"; + +/** Bump when the format changes incompatibly; a blob at another version is + * rejected with an intelligible message rather than mis-imported. */ +export const CATALOG_TRANSFER_VERSION = 1; + +/** A whole non-negative minor-unit price. */ +const PriceSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +/** A whole non-negative integer (counts, day windows). */ +const NonNegativeIntSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +/** A whole positive integer (durations, quantities). */ +const PositiveIntSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); +/** A required, trimmed, non-empty name reference. */ +const NameRefSchema = v.pipe(v.string(), v.trim(), v.minLength(1)); +/** Per-day-count price overrides as they appear in JSON (string day keys). */ +const DayPricesSchema = v.record(v.string(), PriceSchema); + +/** + * The transferable columns of a listing, keyed in camelCase to match + * `ListingInput`. Excludes the id/slug/timestamp columns (regenerated on + * import) and the image/attachment columns (out of scope). Optional fields are + * omitted rather than defaulted so the importing table applies its own column + * defaults; `name` and `maxAttendees` are the only structural requirements. + */ +export const ListingDataSchema = v.object({ + active: v.optional(v.boolean()), + assignBuiltSite: v.optional(v.boolean()), + bookableDays: v.optional(v.array(v.string())), + canPayMore: v.optional(v.boolean()), + closesAt: v.optional(v.nullable(v.string())), + customisableDays: v.optional(v.boolean()), + date: v.optional(v.string()), + dayPrices: v.optional(DayPricesSchema), + description: v.optional(v.string()), + durationDays: v.optional(PositiveIntSchema), + fields: v.optional(v.string()), + hidden: v.optional(v.boolean()), + initialSiteMonths: v.optional(NonNegativeIntSchema), + listingType: v.optional(ListingTypeSchema), + location: v.optional(v.string()), + maxAttendees: NonNegativeIntSchema, + maximumDaysAfter: v.optional(NonNegativeIntSchema), + maxPrice: v.optional(PriceSchema, 0), + maxQuantity: v.optional(PositiveIntSchema), + minimumDaysBefore: v.optional(NonNegativeIntSchema), + monthsPerUnit: v.optional(NonNegativeIntSchema), + name: NameRefSchema, + nonTransferable: v.optional(v.boolean()), + purchaseOnly: v.optional(v.boolean()), + thankYouUrl: v.optional(v.string()), + unitPrice: v.optional(PriceSchema), + useDefaults: v.optional(v.boolean()), + usesLogistics: v.optional(v.boolean()), + webhookUrl: v.optional(v.string()), +}); +export type ListingData = v.InferOutput; + +/** The transferable columns of a group, keyed to match `GroupInput` (members + * live on the envelope, not here). */ +export const GroupDataSchema = v.object({ + description: v.optional(v.string()), + hidden: v.optional(v.boolean()), + hidePackageListings: v.optional(v.boolean()), + isPackage: v.optional(v.boolean()), + maxAttendees: v.optional(NonNegativeIntSchema), + name: NameRefSchema, + termsAndConditions: v.optional(v.string()), +}); +export type GroupData = v.InferOutput; + +/** The package-override fields a membership carries, from either side. `null` + * `packagePrice` means "no override — use the listing's own price". */ +const membershipOverrideEntries = { + dayPrices: v.optional(DayPricesSchema), + packagePrice: v.optional(v.nullable(PriceSchema)), + quantity: v.optional(PositiveIntSchema), +}; + +/** A group a listing belongs to (listing-side view), referenced by group name. */ +export const ListingMembershipSchema = v.object({ + group: NameRefSchema, + ...membershipOverrideEntries, +}); +export type ListingMembership = v.InferOutput; + +/** A member of a group (group-side view), referenced by listing name. */ +export const GroupMemberSchema = v.object({ + listing: NameRefSchema, + ...membershipOverrideEntries, +}); +export type GroupMember = v.InferOutput; + +const VersionSchema = v.literal( + CATALOG_TRANSFER_VERSION, + `Unsupported export version (expected ${CATALOG_TRANSFER_VERSION})`, +); + +/** A listing export: the listing's own fields plus its group memberships (by + * name) and its parent listings (by name). */ +export const ListingTransferSchema = v.object({ + groups: v.optional(v.array(ListingMembershipSchema), []), + kind: v.literal("listing"), + listing: ListingDataSchema, + parents: v.optional(v.array(NameRefSchema), []), + version: VersionSchema, +}); +export type ListingTransfer = v.InferOutput; + +/** A group export: the group's own fields plus its member listings (by name). */ +export const GroupTransferSchema = v.object({ + group: GroupDataSchema, + kind: v.literal("group"), + members: v.optional(v.array(GroupMemberSchema), []), + version: VersionSchema, +}); +export type GroupTransfer = v.InferOutput; + +/** A catalog transfer blob: either a listing or a group export. */ +export const CatalogTransferSchema = v.variant("kind", [ + ListingTransferSchema, + GroupTransferSchema, +]); +export type CatalogTransfer = v.InferOutput; + +/** + * Turn valibot parse issues into a single operator-facing message that names the + * offending fields, so a malformed blob explains *which* field is missing or + * invalid rather than surfacing a raw system error. + */ +export const formatTransferIssues = ( + issues: readonly [v.BaseIssue, ...v.BaseIssue[]], +): string => { + const flat = v.flatten(issues); + const parts: string[] = []; + if (flat.root) parts.push(...flat.root); + for (const [path, messages] of Object.entries(flat.nested ?? {})) { + if (messages && messages.length > 0) { + parts.push(`${path}: ${messages.join("; ")}`); + } + } + if (flat.other) parts.push(...flat.other); + return parts.length > 0 + ? `Invalid catalog file — ${parts.join("; ")}` + : "Invalid catalog file — expected a listing or group export."; +}; diff --git a/src/features/admin/groups.ts b/src/features/admin/groups.ts index ecb1435553..c8df564f34 100644 --- a/src/features/admin/groups.ts +++ b/src/features/admin/groups.ts @@ -131,7 +131,7 @@ const isPackageableMember = ( /** Whether every listing can be a package member, judged against ONE batched * edge load (two queries for the whole member list, never per member). */ -const allPackageableMembers = async ( +export const allPackageableMembers = async ( listings: readonly Parameters[0][], hideListings: boolean | undefined, ): Promise => { diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 214fc152ee..368dcffec5 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -22,6 +22,7 @@ import { import { getGroupDayPrices, groupDayPriceStatements, + listingGroupDayInsertStatements, PRICE_TYPE_GROUP_DAY, } from "#shared/db/listing-prices.ts"; import { queryListingsWithCounts } from "#shared/db/listings.ts"; @@ -448,6 +449,56 @@ export const getPackageDisplayForBookings = ( : getPackageDisplayById(shared); }; +/** Every group a listing belongs to, with this listing's per-package override + * and quantity — the membership facet a catalog export captures for one listing. + * A `null` `package_price` means "no override"; `quantity` defaults to 1. */ +export const getListingGroupMemberships = ( + listingId: number, +): Promise => + queryAll( + "SELECT group_id, listing_id, package_price, quantity FROM group_listings WHERE listing_id = ? ORDER BY group_id ASC", + [listingId], + ); + +/** One membership to (re)create on catalog import: which listing joins which + * group, with its package override, quantity, and per-day overrides. */ +export type ImportedMembership = { + groupId: number; + listingId: number; + packagePrice: number | null; + quantity: number; + dayPrices: DayPrices; +}; + +/** Insert ONE membership row (with its package override + quantity) plus that + * member's `group_day` per-day overrides, inside an existing write transaction — + * the catalog-import writer for a freshly-created listing or group. Targeted (it + * never deletes), so importing a listing into an already-populated package can't + * disturb the group's other members. Shared by both import directions so a + * listing joining its groups and a group gaining its members write memberships + * identically. */ +export const addGroupMembershipTx = async ( + tx: TxScope, + membership: ImportedMembership, +): Promise => { + await tx.execute({ + args: [ + membership.groupId, + membership.listingId, + membership.packagePrice, + membership.quantity, + ], + sql: "INSERT INTO group_listings (group_id, listing_id, package_price, quantity) VALUES (?, ?, ?, ?)", + }); + for (const stmt of listingGroupDayInsertStatements( + membership.groupId, + membership.listingId, + membership.dayPrices, + )) { + await tx.execute(stmt); + } +}; + /** The listing ids that are members of a group, ascending. */ export const getGroupListingIds = async ( groupId: number, diff --git a/src/shared/db/listing-parents.ts b/src/shared/db/listing-parents.ts index 73f25c3373..4606a37ab3 100644 --- a/src/shared/db/listing-parents.ts +++ b/src/shared/db/listing-parents.ts @@ -94,6 +94,21 @@ export const setChildIds = ( childIds: readonly number[], ): Promise => executeBatch(childEdgeStatements(parentId, childIds)); +/** Add `childId` as a child under each of `parentIds` inside an existing write + * transaction — the catalog-import writer for a freshly-created listing that is + * a child of already-existing parents. Additive (one INSERT per parent), so it + * never disturbs a parent's other children the way {@link setChildIdsTx}'s + * replace would. */ +export const addParentEdgesTx = async ( + tx: TxScope, + childId: number, + parentIds: readonly number[], +): Promise => { + for (const parentId of parentIds) { + await tx.execute({ args: [parentId, childId], sql: INSERT_EDGE }); + } +}; + /** Replace a parent's child edges inside an existing write transaction, so the * edge replacement commits atomically with the listing row write (the admin API * create/update path). Mirrors {@link setChildIds} but runs each statement on the diff --git a/src/shared/db/listing-prices.ts b/src/shared/db/listing-prices.ts index c3a1365f7c..951d93a9d6 100644 --- a/src/shared/db/listing-prices.ts +++ b/src/shared/db/listing-prices.ts @@ -95,6 +95,25 @@ export const groupDayPriceStatements = ( return statements; }; +/** The INSERT statements adding ONE listing's `group_day` overrides for ONE + * group — targeted (no group-wide delete), so importing a listing into an + * already-populated package never disturbs the other members' per-day overrides + * the way {@link groupDayPriceStatements}' full-replace would. Entries are + * normalised through {@link parseDayPrices} like every other day-price write. */ +export const listingGroupDayInsertStatements = ( + groupId: number, + listingId: number, + dayPrices: DayPrices, +): PriceStatement[] => + Object.entries(parseDayPrices(dayPrices)).map(([days, price]) => + insertPriceStatement([ + listingId, + PRICE_TYPE_GROUP_DAY, + groupDayPriceId(groupId, days), + price, + ]), + ); + /** A raw `group_day` row as SELECTed for the readers below. */ type GroupDayRow = { listing_id: number; price_id: string; unit_price: number }; diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts new file mode 100644 index 0000000000..09a2a62260 --- /dev/null +++ b/test/lib/catalog-transfer.test.ts @@ -0,0 +1,249 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { + exportGroup, + exportListing, +} from "#routes/admin/catalog-transfer/export.ts"; +import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { + assignListingsToGroup, + getGroupIdsByListingId, + getGroupPackagePrices, + setGroupPackageMembers, +} from "#shared/db/groups.ts"; +import { getParentIds, setChildIds } from "#shared/db/listing-parents.ts"; +import { getGroupDayPrices } from "#shared/db/listing-prices.ts"; +import { getListing } from "#shared/db/listings.ts"; +import { + createTestGroup, + createTestListing, + describeWithEnv, +} from "#test-utils"; + +describeWithEnv("catalog-transfer", { db: true }, () => { + describe("listing round-trip", () => { + test("re-creates a listing with its group membership and parent", async () => { + const group = await createTestGroup({ name: "Regular Group" }); + const parent = await createTestListing({ + name: "Parent Listing", + unitPrice: 5000, + }); + const child = await createTestListing({ + groupId: group.id, + name: "Child Listing", + unitPrice: 1500, + }); + await setChildIds(parent.id, [child.id]); + + const blob = (await exportListing(child.id))!; + expect(blob.kind).toBe("listing"); + expect(blob.parents).toEqual(["Parent Listing"]); + expect(blob.groups).toEqual([{ group: "Regular Group" }]); + expect(blob.listing.unitPrice).toBe(1500); + + // Rename and re-import as a fresh listing referencing the same facets. + blob.listing.name = "Child Copy"; + const result = await importCatalog(blob); + expect(result).toEqual({ + id: expect.any(Number), + kind: "listing", + name: "Child Copy", + ok: true, + }); + if (!result.ok) throw new Error("unreachable"); + + const imported = (await getListing(result.id))!; + expect(imported.name).toBe("Child Copy"); + expect(imported.unit_price).toBe(1500); + // Slug is freshly minted, never copied from the source. + expect(imported.slug).not.toBe(child.slug); + expect(await getGroupIdsByListingId(result.id)).toEqual([group.id]); + expect(await getParentIds(result.id)).toEqual([parent.id]); + }); + + test("carries package overrides and day prices for a package member", async () => { + const group = await createTestGroup({ + isPackage: true, + name: "Bundle", + }); + const member = await createTestListing({ + customisableDays: true, + dayPrices: { 1: 1000, 2: 1800 }, + durationDays: 2, + groupId: group.id, + name: "Flexi Member", + }); + await setGroupPackageMembers(group.id, [ + { + dayPrices: { 1: 900 }, + listingId: member.id, + price: 800, + quantity: 3, + }, + ]); + + const blob = (await exportListing(member.id))!; + expect(blob.groups).toEqual([ + { + dayPrices: { "1": 900 }, + group: "Bundle", + packagePrice: 800, + quantity: 3, + }, + ]); + expect(blob.listing.dayPrices).toEqual({ "1": 1000, "2": 1800 }); + + blob.listing.name = "Flexi Copy"; + const result = await importCatalog(blob); + if (!result.ok) throw new Error(result.error); + + const rows = await getGroupPackagePrices(group.id); + const importedRow = rows.find((r) => r.listing_id === result.id)!; + expect(importedRow.package_price).toBe(800); + expect(importedRow.quantity).toBe(3); + const dayPrices = await getGroupDayPrices(group.id); + expect(dayPrices.get(result.id)).toEqual(new Map([[1, 900]])); + // The existing member's overrides are untouched by the targeted insert. + expect(rows.find((r) => r.listing_id === member.id)?.package_price).toBe( + 800, + ); + }); + }); + + describe("group round-trip", () => { + test("re-creates a package group with its members and overrides", async () => { + const a = await createTestListing({ name: "Pkg A", unitPrice: 1000 }); + const b = await createTestListing({ name: "Pkg B", unitPrice: 2000 }); + const group = await createTestGroup({ isPackage: true, name: "Combo" }); + await assignListingsToGroup([a.id, b.id], group.id); + await setGroupPackageMembers(group.id, [ + { listingId: a.id, price: 800, quantity: 2 }, + { listingId: b.id, price: null, quantity: 1 }, + ]); + + const blob = (await exportGroup(group.id))!; + expect(blob.group.isPackage).toBe(true); + expect(blob.members).toEqual([ + { listing: "Pkg A", packagePrice: 800, quantity: 2 }, + { listing: "Pkg B" }, + ]); + + blob.group.name = "Combo Copy"; + const result = await importCatalog(blob); + if (!result.ok) throw new Error(result.error); + expect(result.kind).toBe("group"); + + const rows = await getGroupPackagePrices(result.id); + expect(rows).toEqual([ + { + group_id: result.id, + listing_id: a.id, + package_price: 800, + quantity: 2, + }, + { + group_id: result.id, + listing_id: b.id, + package_price: null, + quantity: 1, + }, + ]); + }); + }); + + describe("import validation", () => { + test("rejects a listing whose name already exists", async () => { + const listing = await createTestListing({ name: "Existing" }); + const blob = (await exportListing(listing.id))!; + const result = await importCatalog(blob); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain('named "Existing" already exists'); + }); + + test("rejects a listing whose parent does not exist", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 10, name: "Orphan" }, + parents: ["Missing Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain('No listing named "Missing Parent"'); + }); + + test("rejects a listing whose group does not exist", async () => { + const result = await importCatalog({ + groups: [{ group: "Ghost Group" }], + kind: "listing", + listing: { maxAttendees: 10, name: "Joiner" }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain('No group named "Ghost Group"'); + }); + + test("reports missing required fields with field names", async () => { + const result = await importCatalog({ + kind: "listing", + listing: {}, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("listing.name"); + expect(result.error).toContain("listing.maxAttendees"); + }); + + test("rejects an unsupported version", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "V2" }, + version: 2, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Unsupported export version"); + }); + + test("rejects a blob that is not a listing or group export", async () => { + const result = await importCatalog({ kind: "widget" }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Invalid catalog file"); + }); + + test("rejects a group whose member listing does not exist", async () => { + const result = await importCatalog({ + group: { name: "New Bundle" }, + kind: "group", + members: [{ listing: "No Such Listing" }], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain('No listing named "No Such Listing"'); + }); + + test("rejects a group whose members are not the same type", async () => { + await createTestListing({ listingType: "standard", name: "Std" }); + await createTestListing({ listingType: "daily", name: "Daily" }); + const result = await importCatalog({ + group: { name: "Mixed" }, + kind: "group", + members: [{ listing: "Std" }, { listing: "Daily" }], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("must be the same type"); + }); + }); + + test("exporting a missing listing or group returns null", async () => { + expect(await exportListing(9999)).toBeNull(); + expect(await exportGroup(9999)).toBeNull(); + }); +}); From 6fc6cbdd940121e765dcf9edb437a4c9e938dd2b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:38:29 +0000 Subject: [PATCH 03/28] Wire catalog import/export into the admin UI and routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add content-gated admin routes and UI for the transfer engine: - GET /admin/listing/:id/export.json and /admin/groups/:id/export.json download the entity as a JSON attachment (named from the entity). - GET/POST /admin/catalog/import: an upload form that parses the JSON, runs importCatalog, and redirects with a success flash to the created entity's list — or an intelligible error flash on any failure (invalid JSON, missing file, or a validation/resolution error). - Export links on the listing and group detail pages; an "Import from file" button on the listings and groups list pages. - New catalog-transfer i18n namespace. Route- and template-level tests cover download, the round-trip upload, and every error path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/routes.ts | 119 ++++++++++++ src/features/admin/index.ts | 2 + src/locales/en/catalog-transfer.json | 13 ++ src/locales/en/index.ts | 2 + src/ui/templates/admin/catalog-transfer.tsx | 46 +++++ src/ui/templates/admin/dashboard.tsx | 3 + src/ui/templates/admin/groups.tsx | 8 + src/ui/templates/admin/listings.tsx | 5 + test/lib/server-catalog-transfer.test.ts | 176 ++++++++++++++++++ 9 files changed, 374 insertions(+) create mode 100644 src/features/admin/catalog-transfer/routes.ts create mode 100644 src/locales/en/catalog-transfer.json create mode 100644 src/ui/templates/admin/catalog-transfer.tsx create mode 100644 test/lib/server-catalog-transfer.test.ts diff --git a/src/features/admin/catalog-transfer/routes.ts b/src/features/admin/catalog-transfer/routes.ts new file mode 100644 index 0000000000..650513cdf6 --- /dev/null +++ b/src/features/admin/catalog-transfer/routes.ts @@ -0,0 +1,119 @@ +/** + * Admin routes for catalog import/export. + * + * Export: download one listing or group as a JSON blob (see schema.ts). + * Import: upload such a blob to create a new listing or group. All routes are + * content-gated (owner/manager/editor — the same roles that create listings and + * groups). + */ + +import { t } from "#i18n"; +import { CONTENT_MULTIPART, requireContentOr, withAuth } from "#routes/auth.ts"; +import { applyFlash } from "#routes/csrf.ts"; +import { + encodeBody, + errorRedirect, + htmlResponse, + notFoundResponse, + redirect, +} from "#routes/response.ts"; +import { defineRoutes, type TypedRouteHandler } from "#routes/router.ts"; +import { logActivity } from "#shared/db/activityLog.ts"; +import { adminCatalogImportPage } from "#templates/admin/catalog-transfer.tsx"; +import { exportGroup, exportListing } from "./export.ts"; +import { importCatalog } from "./import.ts"; + +const IMPORT_PATH = "/admin/catalog/import"; + +/** Build a JSON file download response. */ +const jsonDownload = (data: unknown, filename: string): Response => + new Response(encodeBody(JSON.stringify(data, null, 2)), { + headers: { + "content-disposition": `attachment; filename="${filename}"`, + "content-type": "application/json; charset=utf-8", + }, + }); + +/** A safe, human-readable download filename from an entity name. */ +const catalogFilename = (kind: string, name: string): string => { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${kind}-${slug || kind}.json`; +}; + +/** GET /admin/listing/:id/export.json — download a listing's export blob. */ +const handleListingExport: TypedRouteHandler< + "GET /admin/listing/:id/export.json" +> = (request, { id }) => + requireContentOr(request, async () => { + const blob = await exportListing(id); + if (!blob) return notFoundResponse(); + return jsonDownload(blob, catalogFilename("listing", blob.listing.name)); + }); + +/** GET /admin/groups/:id/export.json — download a group's export blob. */ +const handleGroupExport: TypedRouteHandler< + "GET /admin/groups/:id/export.json" +> = (request, { id }) => + requireContentOr(request, async () => { + const blob = await exportGroup(id); + if (!blob) return notFoundResponse(); + return jsonDownload(blob, catalogFilename("group", blob.group.name)); + }); + +/** GET /admin/catalog/import — the import upload form. */ +const handleImportGet: TypedRouteHandler<"GET /admin/catalog/import"> = ( + request, +) => + requireContentOr(request, (session) => { + const flash = applyFlash(request); + return htmlResponse( + adminCatalogImportPage(session, flash.error, flash.success), + ); + }); + +/** POST /admin/catalog/import — validate and apply an uploaded blob. */ +const handleImportPost: TypedRouteHandler<"POST /admin/catalog/import"> = ( + request, +) => + withAuth(request, CONTENT_MULTIPART, async (_session, formData) => { + const file = formData.get("catalog_file"); + if (!(file instanceof File) || file.size === 0) { + return errorRedirect(IMPORT_PATH, t("catalog_transfer.no_file")); + } + + let parsed: unknown; + try { + parsed = JSON.parse(await file.text()); + } catch { + return errorRedirect(IMPORT_PATH, t("catalog_transfer.invalid_json")); + } + + const result = await importCatalog(parsed); + if (!result.ok) return errorRedirect(IMPORT_PATH, result.error); + + if (result.kind === "listing") { + await logActivity(`Listing '${result.name}' imported`, result.id); + return redirect( + "/admin/listings", + t("catalog_transfer.imported_listing", { name: result.name }), + true, + ); + } + await logActivity(`Group '${result.name}' imported`); + return redirect( + "/admin/groups", + t("catalog_transfer.imported_group", { name: result.name }), + true, + ); + }); + +/** Catalog import/export routes. */ +export const catalogTransferRoutes = defineRoutes({ + "GET /admin/catalog/import": handleImportGet, + "GET /admin/groups/:id/export.json": handleGroupExport, + "GET /admin/listing/:id/export.json": handleListingExport, + "POST /admin/catalog/import": handleImportPost, +}); diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts index 28fa95d648..d7fbdc1fca 100644 --- a/src/features/admin/index.ts +++ b/src/features/admin/index.ts @@ -19,6 +19,7 @@ import { builtSitesRoutes } from "#routes/admin/built-sites.ts"; import { bulkActionsRoutes } from "#routes/admin/bulk-actions.ts"; import { bulkEmailRoutes } from "#routes/admin/bulk-email.ts"; import { calendarRoutes } from "#routes/admin/calendar.ts"; +import { catalogTransferRoutes } from "#routes/admin/catalog-transfer/routes.ts"; import { contactHistoryRoutes } from "#routes/admin/contact-history.ts"; import { dashboardRoutes } from "#routes/admin/dashboard.ts"; import { debugRoutes } from "#routes/admin/debug.ts"; @@ -91,6 +92,7 @@ const adminRouteModules: Record[] = [ updateRoutes, backupRoutes, supportRoutes, + catalogTransferRoutes, ]; const adminRoutes = reduce( diff --git a/src/locales/en/catalog-transfer.json b/src/locales/en/catalog-transfer.json new file mode 100644 index 0000000000..8e0fc1e7c3 --- /dev/null +++ b/src/locales/en/catalog-transfer.json @@ -0,0 +1,13 @@ +{ + "catalog_transfer.export_link": "Export", + "catalog_transfer.import_button": "Import from file", + "catalog_transfer.page_title": "Import a listing or group", + "catalog_transfer.heading": "Import a listing or group", + "catalog_transfer.description": "Upload a JSON file exported from this or another site to re-create a listing or group — with its prices, group memberships, and parent references. Any referenced parent listings and groups must already exist here, and the imported name must not already be in use.", + "catalog_transfer.file_label": "Catalog file (.json)", + "catalog_transfer.upload_button": "Import", + "catalog_transfer.no_file": "Please select a JSON file to import.", + "catalog_transfer.invalid_json": "The file is not valid JSON.", + "catalog_transfer.imported_listing": "Imported listing {name}", + "catalog_transfer.imported_group": "Imported group {name}" +} diff --git a/src/locales/en/index.ts b/src/locales/en/index.ts index a805bf4d00..4a24bef306 100644 --- a/src/locales/en/index.ts +++ b/src/locales/en/index.ts @@ -10,6 +10,7 @@ import builder from "./builder.json" with { type: "json" }; import builtSites from "./built-sites.json" with { type: "json" }; import bulkActions from "./bulk-actions.json" with { type: "json" }; import bulkEmail from "./bulk-email.json" with { type: "json" }; +import catalogTransfer from "./catalog-transfer.json" with { type: "json" }; import common from "./common.json" with { type: "json" }; import csv from "./csv.json" with { type: "json" }; import datePicker from "./date-picker.json" with { type: "json" }; @@ -55,6 +56,7 @@ const en: Record = { ...builtSites, ...bulkActions, ...bulkEmail, + ...catalogTransfer, ...common, ...csv, ...datePicker, diff --git a/src/ui/templates/admin/catalog-transfer.tsx b/src/ui/templates/admin/catalog-transfer.tsx new file mode 100644 index 0000000000..dfb3c6ce3c --- /dev/null +++ b/src/ui/templates/admin/catalog-transfer.tsx @@ -0,0 +1,46 @@ +/** + * Admin "import a listing or group" page — a single upload form that accepts a + * JSON blob exported from this or another site (the blob's `kind` decides + * whether a listing or a group is created). + */ + +import { t } from "#i18n"; +import { CsrfForm, Flash } from "#shared/forms.tsx"; +import type { AdminSession } from "#shared/types.ts"; +import { AdminNav } from "#templates/admin/nav.tsx"; +import { SubmitButton } from "#templates/components/actions.tsx"; +import { Layout } from "#templates/layout.tsx"; + +export const adminCatalogImportPage = ( + session: AdminSession, + error?: string, + success?: string, +): string => + String( + + +
+

{t("catalog_transfer.heading")}

+

{t("catalog_transfer.description")}

+
+ + + + + {t("catalog_transfer.upload_button")} + + +
, + ); diff --git a/src/ui/templates/admin/dashboard.tsx b/src/ui/templates/admin/dashboard.tsx index c6bed50a76..9d1aa082f2 100644 --- a/src/ui/templates/admin/dashboard.tsx +++ b/src/ui/templates/admin/dashboard.tsx @@ -423,6 +423,9 @@ export const adminListingsPage = ( {t("admin.dashboard.add_listing")} + + {t("catalog_transfer.import_button")} +

)} diff --git a/src/ui/templates/admin/groups.tsx b/src/ui/templates/admin/groups.tsx index 6afa51de74..ee7120ea82 100644 --- a/src/ui/templates/admin/groups.tsx +++ b/src/ui/templates/admin/groups.tsx @@ -67,6 +67,9 @@ export const adminGroupsPage = ( {!isReadOnly() && (

+ + {t("catalog_transfer.import_button")} + {t("groups.add_group")} @@ -570,6 +573,11 @@ export const adminGroupDetailPage = ( )} +

  • + + {t("catalog_transfer.export_link")} + +
  • {t("groups.detail.delete_group")} diff --git a/src/ui/templates/admin/listings.tsx b/src/ui/templates/admin/listings.tsx index 3608ce22e4..a036264b47 100644 --- a/src/ui/templates/admin/listings.tsx +++ b/src/ui/templates/admin/listings.tsx @@ -590,6 +590,11 @@ const ListingActionNav = ({ {t("listings_table.log")}
  • +
  • + + {t("catalog_transfer.export_link")} + +
  • {!listing.purchase_only && (
  • diff --git a/test/lib/server-catalog-transfer.test.ts b/test/lib/server-catalog-transfer.test.ts new file mode 100644 index 0000000000..7e62685a9f --- /dev/null +++ b/test/lib/server-catalog-transfer.test.ts @@ -0,0 +1,176 @@ +import { expect } from "@std/expect"; +import { describe, it as test } from "@std/testing/bdd"; +import { handleRequest } from "#routes"; +import { signCsrfToken } from "#shared/csrf.ts"; +import { getGroupIdsByListingId } from "#shared/db/groups.ts"; +import { getAllListings } from "#shared/db/listings.ts"; +import { + adminGet, + createTestGroup, + createTestListing, + describeWithEnv, + expectFlashRedirect, + getTestSession, + mockMultipartRequest, + testRequiresAuth, +} from "#test-utils"; + +/** POST a JSON blob to the import endpoint as an uploaded file. */ +const importUpload = async (blob: unknown): Promise => { + const { cookie } = await getTestSession(); + const csrfToken = await signCsrfToken(); + return handleRequest( + mockMultipartRequest( + "/admin/catalog/import", + { csrf_token: csrfToken }, + cookie, + { + contentType: "application/json", + data: new TextEncoder().encode(JSON.stringify(blob)), + fieldName: "catalog_file", + name: "catalog.json", + }, + ), + ); +}; + +describeWithEnv("server (catalog transfer)", { db: true }, () => { + describe("GET export", () => { + testRequiresAuth("/admin/listing/1/export.json"); + + test("downloads a listing as a JSON attachment", async () => { + const listing = await createTestListing({ name: "Exportable" }); + const response = await adminGet( + `/admin/listing/${listing.id}/export.json`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "application/json", + ); + expect(response.headers.get("content-disposition")).toContain( + 'attachment; filename="listing-exportable.json"', + ); + const blob = await response.json(); + expect(blob.kind).toBe("listing"); + expect(blob.listing.name).toBe("Exportable"); + }); + + test("downloads a group as a JSON attachment", async () => { + const group = await createTestGroup({ name: "Group X" }); + const response = await adminGet(`/admin/groups/${group.id}/export.json`); + expect(response.status).toBe(200); + const blob = await response.json(); + expect(blob.kind).toBe("group"); + expect(blob.group.name).toBe("Group X"); + }); + + test("returns 404 for a missing listing", async () => { + const response = await adminGet("/admin/listing/9999/export.json"); + expect(response.status).toBe(404); + }); + }); + + describe("import page", () => { + testRequiresAuth("/admin/catalog/import"); + + test("renders the upload form", async () => { + const response = await adminGet("/admin/catalog/import"); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Import a listing or group"); + expect(html).toContain('name="catalog_file"'); + }); + }); + + describe("POST import", () => { + testRequiresAuth("/admin/catalog/import", { + body: {}, + method: "POST", + }); + + test("creates a listing from an uploaded blob and redirects", async () => { + const group = await createTestGroup({ name: "Host Group" }); + const response = await importUpload({ + groups: [{ group: "Host Group" }], + kind: "listing", + listing: { maxAttendees: 25, name: "Imported One", unitPrice: 900 }, + version: 1, + }); + await expectFlashRedirect( + "/admin/listings", + "Imported listing Imported One", + )(response); + + const created = (await getAllListings()).find( + (l) => l.name === "Imported One", + )!; + expect(created.unit_price).toBe(900); + expect(await getGroupIdsByListingId(created.id)).toEqual([group.id]); + }); + + test("creates a group from an uploaded blob", async () => { + const response = await importUpload({ + group: { name: "Imported Group" }, + kind: "group", + members: [], + version: 1, + }); + await expectFlashRedirect( + "/admin/groups", + "Imported group Imported Group", + )(response); + }); + + test("rejects an invalid JSON file", async () => { + const { cookie } = await getTestSession(); + const csrfToken = await signCsrfToken(); + const response = await handleRequest( + mockMultipartRequest( + "/admin/catalog/import", + { csrf_token: csrfToken }, + cookie, + { + contentType: "application/json", + data: new TextEncoder().encode("{ not json"), + fieldName: "catalog_file", + name: "bad.json", + }, + ), + ); + await expectFlashRedirect( + "/admin/catalog/import", + "The file is not valid JSON.", + false, + )(response); + }); + + test("rejects a missing file", async () => { + const { cookie } = await getTestSession(); + const csrfToken = await signCsrfToken(); + const response = await handleRequest( + mockMultipartRequest( + "/admin/catalog/import", + { csrf_token: csrfToken }, + cookie, + ), + ); + await expectFlashRedirect( + "/admin/catalog/import", + "Please select a JSON file to import.", + false, + )(response); + }); + + test("surfaces a validation error (duplicate name) as a flash", async () => { + await createTestListing({ name: "Clash" }); + const response = await importUpload({ + kind: "listing", + listing: { maxAttendees: 1, name: "Clash" }, + version: 1, + }); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/admin/catalog/import"); + expect(location).toContain("flash="); + }); + }); +}); From 40cb032409029fef4de97a5a831eacae561c612a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:53:32 +0000 Subject: [PATCH 04/28] Mutation-harden the catalog-transfer modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthen tests so the transfer schema/export/import/routes reach a 100% mutation kill rate, and record the genuinely-equivalent survivors: - Cover a package member priced explicitly free (0 vs null), closesAt preservation and the post-import price re-sync, an incompatible parent edge, a package-member-with-parent conflict, root-type and nested-field parse messages, and activity logging on import. - Simplify formatTransferIssues to root+nested only (the object-variant schemas never produce a pathless "other" issue), removing dead branches. - Record array/object `?? → ||` equivalents in equivalent-mutants.txt. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- scripts/mutation/equivalent-mutants.txt | 6 ++ src/features/admin/catalog-transfer/import.ts | 2 +- src/features/admin/catalog-transfer/schema.ts | 21 +++-- test/lib/catalog-transfer.test.ts | 84 ++++++++++++++++++- test/lib/server-catalog-transfer.test.ts | 6 ++ 5 files changed, 104 insertions(+), 15 deletions(-) diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index 1717dea87a..fced994e81 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -209,3 +209,9 @@ src/shared/db/listing-prices.ts:109:45 ?? → || # foldGroupDayRows result.g # value equals the "" fallback. src/features/admin/attendee-page.ts:266:60 ?? → || # ctx.query.get("token"): string|null; the only falsy-non-null value "" equals the "" fallback src/features/admin/entity-pages.ts:230:28 ?? → || # opts.baseUrl: string|undefined; the only falsy-non-null value "" equals the "" fallback +src/shared/db/name-registry.ts:85:51 ?? → || # matchName index.get(): number[]|undefined — an array is always truthy +src/shared/db/name-registry.ts:105:20 ?? → || # isNameTakenAnywhere index.get(): number[]|undefined — an array is always truthy +src/features/admin/catalog-transfer/import.ts:114:27 ?? → || # membershipSpec quantity: from the PositiveInt schema (≥1) or undefined — never 0, so ?? and || coincide +src/features/admin/catalog-transfer/import.ts:161:39 ?? → || # validateParentEdges input.groupIds: number[]|undefined — an array is always truthy +src/features/admin/catalog-transfer/schema.ts:158:18 ?? → || # formatTransferIssues flat.root: string[]|undefined — an array is always truthy +src/features/admin/catalog-transfer/schema.ts:159:34 ?? → || # formatTransferIssues flat.nested: object|undefined — an object is always truthy diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index 67f6ab7cd8..5ddd8fd575 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -130,7 +130,7 @@ const listingDataToInput = ( ...rest, attachmentName: "", attachmentUrl: "", - closesAt: closesAt ?? undefined, + closesAt: closesAt === null ? undefined : closesAt, dayPrices: dayPrices === undefined ? undefined : parseDayPrices(dayPrices), groupIds, imageUrl: "", diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 54389f31aa..b2d68f2a81 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -150,16 +150,15 @@ export type CatalogTransfer = v.InferOutput; export const formatTransferIssues = ( issues: readonly [v.BaseIssue, ...v.BaseIssue[]], ): string => { + // Every failure of these (object variant) schemas lands in `root` (the whole + // blob is the wrong type) or `nested` (a keyed field is wrong), so those two + // buckets always carry at least one message — no empty-parts fallback needed. const flat = v.flatten(issues); - const parts: string[] = []; - if (flat.root) parts.push(...flat.root); - for (const [path, messages] of Object.entries(flat.nested ?? {})) { - if (messages && messages.length > 0) { - parts.push(`${path}: ${messages.join("; ")}`); - } - } - if (flat.other) parts.push(...flat.other); - return parts.length > 0 - ? `Invalid catalog file — ${parts.join("; ")}` - : "Invalid catalog file — expected a listing or group export."; + const parts = [ + ...(flat.root ?? []), + ...Object.entries(flat.nested ?? {}).map( + ([path, messages]) => `${path}: ${(messages as string[]).join("; ")}`, + ), + ]; + return `Invalid catalog file — ${parts.join("; ")}`; }; diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index 09a2a62260..d884491f91 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -11,6 +11,7 @@ import { getGroupPackagePrices, setGroupPackageMembers, } from "#shared/db/groups.ts"; +import { execute } from "#shared/db/client.ts"; import { getParentIds, setChildIds } from "#shared/db/listing-parents.ts"; import { getGroupDayPrices } from "#shared/db/listing-prices.ts"; import { getListing } from "#shared/db/listings.ts"; @@ -61,6 +62,30 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(await getParentIds(result.id)).toEqual([parent.id]); }); + test("preserves closesAt and re-syncs the derived price rows", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + closesAt: "2030-01-01T00:00:00.000Z", + maxAttendees: 5, + name: "Priced Import", + unitPrice: 2500, + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + + const imported = (await getListing(result.id))!; + expect(imported.closes_at).toBe("2030-01-01T00:00:00.000Z"); + // The derived listing_prices base row is re-synced from unit_price after + // the transactional insert (which bypasses the table wrapper). + const priceRows = await execute( + "SELECT unit_price FROM listing_prices WHERE listing_id = ? AND price_type = 'base'", + [result.id], + ); + expect(priceRows.rows.map((r) => Number(r.unit_price))).toEqual([2500]); + }); + test("carries package overrides and day prices for a package member", async () => { const group = await createTestGroup({ isPackage: true, @@ -114,11 +139,15 @@ describeWithEnv("catalog-transfer", { db: true }, () => { test("re-creates a package group with its members and overrides", async () => { const a = await createTestListing({ name: "Pkg A", unitPrice: 1000 }); const b = await createTestListing({ name: "Pkg B", unitPrice: 2000 }); + // A member that is explicitly FREE in the package (price 0) — distinct + // from "no override" (null); the two must round-trip separately. + const c = await createTestListing({ name: "Pkg C", unitPrice: 3000 }); const group = await createTestGroup({ isPackage: true, name: "Combo" }); - await assignListingsToGroup([a.id, b.id], group.id); + await assignListingsToGroup([a.id, b.id, c.id], group.id); await setGroupPackageMembers(group.id, [ { listingId: a.id, price: 800, quantity: 2 }, { listingId: b.id, price: null, quantity: 1 }, + { listingId: c.id, price: 0, quantity: 1 }, ]); const blob = (await exportGroup(group.id))!; @@ -126,6 +155,7 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(blob.members).toEqual([ { listing: "Pkg A", packagePrice: 800, quantity: 2 }, { listing: "Pkg B" }, + { listing: "Pkg C", packagePrice: 0 }, ]); blob.group.name = "Combo Copy"; @@ -147,6 +177,12 @@ describeWithEnv("catalog-transfer", { db: true }, () => { package_price: null, quantity: 1, }, + { + group_id: result.id, + listing_id: c.id, + package_price: 0, + quantity: 1, + }, ]); }); }); @@ -208,11 +244,20 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(result.error).toContain("Unsupported export version"); }); - test("rejects a blob that is not a listing or group export", async () => { + test("rejects a blob with an unknown kind (nested field message)", async () => { const result = await importCatalog({ kind: "widget" }); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); - expect(result.error).toContain("Invalid catalog file"); + expect(result.error).toContain("kind:"); + expect(result.error).toContain("listing"); + }); + + test("rejects a blob that is not an object (root type message)", async () => { + const result = await importCatalog(42); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Invalid catalog file — Invalid type"); + expect(result.error).toContain("Object"); }); test("rejects a group whose member listing does not exist", async () => { @@ -227,6 +272,39 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(result.error).toContain('No listing named "No Such Listing"'); }); + test("rejects a listing whose parent edge is incompatible", async () => { + // A daily child cannot sit under a standard parent (the child inherits the + // parent's date, which a standard listing has none of) — validateParentEdges + // reuses the edge editor's rule and must reject the import. + await createTestListing({ listingType: "standard", name: "Std Parent" }); + const result = await importCatalog({ + kind: "listing", + listing: { listingType: "daily", maxAttendees: 10, name: "Daily Kid" }, + parents: ["Std Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error.toLowerCase()).toContain("daily"); + }); + + test("rejects a listing that is both a package member and a child", async () => { + const pkg = await createTestGroup({ isPackage: true, name: "Pkg Group" }); + await createTestListing({ name: "Some Parent" }); + const result = await importCatalog({ + groups: [{ group: "Pkg Group" }], + kind: "listing", + listing: { maxAttendees: 10, name: "Torn" }, + parents: ["Some Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("cannot also be an add-on child"); + // Reference the created package so the binding is used. + expect(pkg.is_package).toBe(true); + }); + test("rejects a group whose members are not the same type", async () => { await createTestListing({ listingType: "standard", name: "Std" }); await createTestListing({ listingType: "daily", name: "Daily" }); diff --git a/test/lib/server-catalog-transfer.test.ts b/test/lib/server-catalog-transfer.test.ts index 7e62685a9f..238d8613c3 100644 --- a/test/lib/server-catalog-transfer.test.ts +++ b/test/lib/server-catalog-transfer.test.ts @@ -10,6 +10,7 @@ import { createTestListing, describeWithEnv, expectFlashRedirect, + getAllActivityLog, getTestSession, mockMultipartRequest, testRequiresAuth, @@ -106,6 +107,9 @@ describeWithEnv("server (catalog transfer)", { db: true }, () => { )!; expect(created.unit_price).toBe(900); expect(await getGroupIdsByListingId(created.id)).toEqual([group.id]); + // The import is recorded in the activity log. + const log = await getAllActivityLog(); + expect(log.some((e) => e.message.includes("Imported One"))).toBe(true); }); test("creates a group from an uploaded blob", async () => { @@ -119,6 +123,8 @@ describeWithEnv("server (catalog transfer)", { db: true }, () => { "/admin/groups", "Imported group Imported Group", )(response); + const log = await getAllActivityLog(); + expect(log.some((e) => e.message.includes("Imported Group"))).toBe(true); }); test("rejects an invalid JSON file", async () => { From 0e219bd0c73705e8576cb463ea9b7943d21b25f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 19:58:08 +0000 Subject: [PATCH 05/28] Cover the new export/import UI links and the entity-pages refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rendering assertions for the listing/group export links and the import-from-file buttons, and test the entity-pages activity "view all" link — bringing every template/module the feature touched under a changed test so the mutation gate has its killers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- test/templates/admin/dashboard.test.ts | 2 ++ test/templates/admin/entity-pages.test.ts | 20 ++++++++++++++++++++ test/templates/admin/groups.test.ts | 2 ++ test/templates/admin/listings.test.ts | 10 ++++++++++ 4 files changed, 34 insertions(+) diff --git a/test/templates/admin/dashboard.test.ts b/test/templates/admin/dashboard.test.ts index b02de33562..c279a1cb04 100644 --- a/test/templates/admin/dashboard.test.ts +++ b/test/templates/admin/dashboard.test.ts @@ -756,6 +756,8 @@ describe("adminListingsPage", () => { expect(html).toContain("Deactivated"); expect(html).toContain("Old Show"); expect(html.indexOf("Active Show")).toBeLessThan(html.indexOf("Old Show")); + // The actions bar offers the import-from-file entry point. + expect(html).toContain('href="/admin/catalog/import"'); }); test("omits the deactivated heading when every listing is active", () => { diff --git a/test/templates/admin/entity-pages.test.ts b/test/templates/admin/entity-pages.test.ts index a8c7c7a18c..65f54afa3c 100644 --- a/test/templates/admin/entity-pages.test.ts +++ b/test/templates/admin/entity-pages.test.ts @@ -59,6 +59,26 @@ describe("summary section", () => { }); }); +describe("activity section", () => { + test("renders a 'view all' link when viewAllHref is set", () => { + const html = String( + renderSection({ + entries: [], + kind: "activity", + viewAllHref: "/admin/listing/7/log", + }), + ); + expect(html).toContain(''); + }); + + test("omits the 'view all' link when viewAllHref is null", () => { + const html = String( + renderSection({ entries: [], kind: "activity", viewAllHref: null }), + ); + expect(html).not.toContain(" { const plain = [ { diff --git a/test/templates/admin/groups.test.ts b/test/templates/admin/groups.test.ts index c4f12ee2a2..1f584b3123 100644 --- a/test/templates/admin/groups.test.ts +++ b/test/templates/admin/groups.test.ts @@ -40,6 +40,8 @@ describe("adminGroupDetailPage", () => { expect(html).toContain("20 / 50"); expect(html).toContain("30 remain"); expect(html).toContain("across all listings"); + // The detail page links to the group's JSON export. + expect(html).toContain(`/admin/groups/${group.id}/export.json`); }); test("Group Attendees row drops cap fragment when group is uncapped", () => { diff --git a/test/templates/admin/listings.test.ts b/test/templates/admin/listings.test.ts index e797a9b0bc..9df7d412e4 100644 --- a/test/templates/admin/listings.test.ts +++ b/test/templates/admin/listings.test.ts @@ -454,6 +454,16 @@ describe("adminListingPage", () => { expect(html).toContain("Test Listing"); }); + test("links to the JSON export", () => { + const html = adminListingPage({ + allowedDomain: "localhost", + attendees: [], + listing, + session: TEST_SESSION, + }); + expect(html).toContain(`/admin/listing/${listing.id}/export.json`); + }); + test("shows the listing ticket price in the details table", () => { const paidListing = testListingWithCount({ unit_price: 1250 }); const html = adminListingPage({ From b9a8962ffce5ff22e3bad44b910e612cf7880305 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 20:32:18 +0000 Subject: [PATCH 06/28] Move import-only membership writers out of shared db files Extract the catalog-import group-membership read/write helpers into src/features/admin/catalog-transfer/membership.ts, reverting the additions to src/shared/db/groups.ts and src/shared/db/listing-prices.ts so those heavily-shared modules leave the change set entirely (addGroupMembershipTx now reuses the existing groupDayPriceStatements, dropping its leading full-group DELETE). Bring the covering tests for the remaining touched modules into the change set with real assertions: addParentEdgesTx, the group add-listings activity log, an API-level group name-uniqueness rejection, and the sold-hidden-package delete guard. Refresh the shifted listing-parents equivalent-mutant line numbers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- scripts/mutation/equivalent-mutants.txt | 4 +- src/features/admin/catalog-transfer/export.ts | 2 +- src/features/admin/catalog-transfer/import.ts | 8 +-- .../admin/catalog-transfer/membership.ts | 63 +++++++++++++++++++ src/shared/db/groups.ts | 51 --------------- src/shared/db/listing-prices.ts | 19 ------ test/admin-api-groups.test.ts | 22 +++++++ test/lib/catalog-transfer.test.ts | 2 +- test/lib/server-group-packages.test.ts | 2 + test/lib/server-groups.test.ts | 6 ++ test/lib/server-listing-parents.test.ts | 24 +++++++ 11 files changed, 124 insertions(+), 79 deletions(-) create mode 100644 src/features/admin/catalog-transfer/membership.ts diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index fced994e81..53987e0e55 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -62,7 +62,9 @@ src/ui/templates/public/shared.tsx:318:41 ?? → || # span: null or a duratio src/ui/templates/public/reservations.tsx:1546:50 ?? → || # addOns?.some(...) is boolean|undefined; ?? and || agree on every boolean and on undefined # Parent/child relationship DB (listing-parents.ts). -src/shared/db/listing-parents.ts:118:21 ?? → || # result.get(key): ListingWithCount[]|undefined, arrays always truthy; ?? and || agree +src/shared/db/listing-parents.ts:156:21 ?? → || # result.get(key): ListingWithCount[]|undefined, arrays always truthy; ?? and || agree +src/shared/db/listing-parents.ts:213:44 ?? → || # childrenByParent.get(id): ListingWithCount[]|undefined — an array is always truthy +src/shared/db/listing-parents.ts:214:43 ?? → || # parentsByChild.get(id): ListingWithCount[]|undefined — an array is always truthy # Invariant-based, confirmed by reading toUnits: `lineIdxs` comes from toUnits, # which assigns lineIdx as the flatMap array index, so the sort's input is diff --git a/src/features/admin/catalog-transfer/export.ts b/src/features/admin/catalog-transfer/export.ts index 64553dd2c5..bfd6ee5ade 100644 --- a/src/features/admin/catalog-transfer/export.ts +++ b/src/features/admin/catalog-transfer/export.ts @@ -14,7 +14,6 @@ import { mapNotNullish } from "#fp"; import { getAllGroupNames, getGroupPackagePrices, - getListingGroupMemberships, groupsTable, } from "#shared/db/groups.ts"; import { getParentIds } from "#shared/db/listing-parents.ts"; @@ -27,6 +26,7 @@ import { getStoredListingWithCount, listingsTable, } from "#shared/db/listings.ts"; +import { getListingGroupMemberships } from "./membership.ts"; import { CATALOG_TRANSFER_VERSION, GroupDataSchema, diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index 5ddd8fd575..9d484f10f9 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -19,12 +19,7 @@ import { generateUniqueGroupSlug, } from "#routes/admin/groups.ts"; import { writeRowInTransaction } from "#shared/db/client.ts"; -import { - addGroupMembershipTx, - type GroupInput, - groupsTable, - type ImportedMembership, -} from "#shared/db/groups.ts"; +import { type GroupInput, groupsTable } from "#shared/db/groups.ts"; import { addParentEdgesTx } from "#shared/db/listing-parents.ts"; import { syncListingPrices } from "#shared/db/listing-prices.ts"; import { @@ -54,6 +49,7 @@ import { normalizeDurationDays, parseDayPrices, } from "#shared/types.ts"; +import { addGroupMembershipTx, type ImportedMembership } from "./membership.ts"; import { CatalogTransferSchema, formatTransferIssues, diff --git a/src/features/admin/catalog-transfer/membership.ts b/src/features/admin/catalog-transfer/membership.ts new file mode 100644 index 0000000000..f648b210d6 --- /dev/null +++ b/src/features/admin/catalog-transfer/membership.ts @@ -0,0 +1,63 @@ +/** + * Group-membership read/write helpers used only by catalog import/export. + * + * Kept out of `#shared/db/groups.ts` so the transfer feature owns its own + * membership plumbing: exporting a listing's memberships, and (re)creating a + * membership row — with its package override, quantity, and per-day overrides — + * for a freshly-imported listing or group. + */ + +import type { TxScope } from "#shared/db/client.ts"; +import { queryAll } from "#shared/db/client.ts"; +import { groupDayPriceStatements } from "#shared/db/listing-prices.ts"; +import type { DayPrices, GroupListing } from "#shared/types.ts"; + +/** Every group a listing belongs to, with this listing's per-package override + * and quantity — the membership facet a catalog export captures for one listing. + * A `null` `package_price` means "no override"; `quantity` defaults to 1. */ +export const getListingGroupMemberships = ( + listingId: number, +): Promise => + queryAll( + "SELECT group_id, listing_id, package_price, quantity FROM group_listings WHERE listing_id = ? ORDER BY group_id ASC", + [listingId], + ); + +/** One membership to (re)create on catalog import: which listing joins which + * group, with its package override, quantity, and per-day overrides. */ +export type ImportedMembership = { + groupId: number; + listingId: number; + packagePrice: number | null; + quantity: number; + dayPrices: DayPrices; +}; + +/** Insert ONE membership row (with its package override + quantity) plus that + * member's `group_day` per-day overrides, inside an existing write transaction — + * the catalog-import writer for a freshly-created listing or group. Targeted (it + * never deletes), so importing a listing into an already-populated package can't + * disturb the group's other members. Shared by both import directions so a + * listing joining its groups and a group gaining its members write memberships + * identically. */ +export const addGroupMembershipTx = async ( + tx: TxScope, + membership: ImportedMembership, +): Promise => { + await tx.execute({ + args: [ + membership.groupId, + membership.listingId, + membership.packagePrice, + membership.quantity, + ], + sql: "INSERT INTO group_listings (group_id, listing_id, package_price, quantity) VALUES (?, ?, ?, ?)", + }); + // Reuse the package-save group_day builder, but drop its leading full-group + // DELETE (`.slice(1)`): a fresh member's overrides are inserted targeted, so + // importing into an already-populated package can't wipe the other members'. + const dayStatements = groupDayPriceStatements(membership.groupId, [ + { dayPrices: membership.dayPrices, listingId: membership.listingId }, + ]).slice(1); + for (const stmt of dayStatements) await tx.execute(stmt); +}; diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 368dcffec5..214fc152ee 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -22,7 +22,6 @@ import { import { getGroupDayPrices, groupDayPriceStatements, - listingGroupDayInsertStatements, PRICE_TYPE_GROUP_DAY, } from "#shared/db/listing-prices.ts"; import { queryListingsWithCounts } from "#shared/db/listings.ts"; @@ -449,56 +448,6 @@ export const getPackageDisplayForBookings = ( : getPackageDisplayById(shared); }; -/** Every group a listing belongs to, with this listing's per-package override - * and quantity — the membership facet a catalog export captures for one listing. - * A `null` `package_price` means "no override"; `quantity` defaults to 1. */ -export const getListingGroupMemberships = ( - listingId: number, -): Promise => - queryAll( - "SELECT group_id, listing_id, package_price, quantity FROM group_listings WHERE listing_id = ? ORDER BY group_id ASC", - [listingId], - ); - -/** One membership to (re)create on catalog import: which listing joins which - * group, with its package override, quantity, and per-day overrides. */ -export type ImportedMembership = { - groupId: number; - listingId: number; - packagePrice: number | null; - quantity: number; - dayPrices: DayPrices; -}; - -/** Insert ONE membership row (with its package override + quantity) plus that - * member's `group_day` per-day overrides, inside an existing write transaction — - * the catalog-import writer for a freshly-created listing or group. Targeted (it - * never deletes), so importing a listing into an already-populated package can't - * disturb the group's other members. Shared by both import directions so a - * listing joining its groups and a group gaining its members write memberships - * identically. */ -export const addGroupMembershipTx = async ( - tx: TxScope, - membership: ImportedMembership, -): Promise => { - await tx.execute({ - args: [ - membership.groupId, - membership.listingId, - membership.packagePrice, - membership.quantity, - ], - sql: "INSERT INTO group_listings (group_id, listing_id, package_price, quantity) VALUES (?, ?, ?, ?)", - }); - for (const stmt of listingGroupDayInsertStatements( - membership.groupId, - membership.listingId, - membership.dayPrices, - )) { - await tx.execute(stmt); - } -}; - /** The listing ids that are members of a group, ascending. */ export const getGroupListingIds = async ( groupId: number, diff --git a/src/shared/db/listing-prices.ts b/src/shared/db/listing-prices.ts index 951d93a9d6..c3a1365f7c 100644 --- a/src/shared/db/listing-prices.ts +++ b/src/shared/db/listing-prices.ts @@ -95,25 +95,6 @@ export const groupDayPriceStatements = ( return statements; }; -/** The INSERT statements adding ONE listing's `group_day` overrides for ONE - * group — targeted (no group-wide delete), so importing a listing into an - * already-populated package never disturbs the other members' per-day overrides - * the way {@link groupDayPriceStatements}' full-replace would. Entries are - * normalised through {@link parseDayPrices} like every other day-price write. */ -export const listingGroupDayInsertStatements = ( - groupId: number, - listingId: number, - dayPrices: DayPrices, -): PriceStatement[] => - Object.entries(parseDayPrices(dayPrices)).map(([days, price]) => - insertPriceStatement([ - listingId, - PRICE_TYPE_GROUP_DAY, - groupDayPriceId(groupId, days), - price, - ]), - ); - /** A raw `group_day` row as SELECTed for the readers below. */ type GroupDayRow = { listing_id: number; price_id: string; unit_price: number }; diff --git a/test/admin-api-groups.test.ts b/test/admin-api-groups.test.ts index b2fff85689..144250efe9 100644 --- a/test/admin-api-groups.test.ts +++ b/test/admin-api-groups.test.ts @@ -250,6 +250,28 @@ describeWithEnv("Admin API - Groups", { db: true }, () => { ); }); + test("rejects a group name already used by another group", async () => { + await assertJson( + apiRequest("/api/admin/groups", { + body: { name: "Unique API Group" }, + method: "POST", + }), + 201, + ); + await assertJson( + apiRequest("/api/admin/groups", { + body: { name: "Unique API Group" }, + method: "POST", + }), + 400, + (body) => { + expect(body.error).toBe( + "Name is already in use by another listing or group", + ); + }, + ); + }); + test("auto-generates unique slug", async () => { const result1 = await assertJson( apiRequest("/api/admin/groups", { diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index d884491f91..8de55607d8 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -5,13 +5,13 @@ import { exportListing, } from "#routes/admin/catalog-transfer/export.ts"; import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { execute } from "#shared/db/client.ts"; import { assignListingsToGroup, getGroupIdsByListingId, getGroupPackagePrices, setGroupPackageMembers, } from "#shared/db/groups.ts"; -import { execute } from "#shared/db/client.ts"; import { getParentIds, setChildIds } from "#shared/db/listing-parents.ts"; import { getGroupDayPrices } from "#shared/db/listing-prices.ts"; import { getListing } from "#shared/db/listings.ts"; diff --git a/test/lib/server-group-packages.test.ts b/test/lib/server-group-packages.test.ts index 161e64f5f3..7f83c2683c 100644 --- a/test/lib/server-group-packages.test.ts +++ b/test/lib/server-group-packages.test.ts @@ -877,6 +877,8 @@ describeWithEnv("server (admin group packages)", { db: true }, () => { }); expect(blocked.response.status).toBe(302); expect(await groupsTable.findById(group.id)).not.toBeNull(); + // The block surfaces the un-hide-first guidance rather than deleting. + expect(blocked.response.headers.get("location")).toContain("flash="); // …and the sold ticket keeps concealing the member behind the package name. const { handleRequest } = await import("#routes"); diff --git a/test/lib/server-groups.test.ts b/test/lib/server-groups.test.ts index 29dd4b2220..76460e52a0 100644 --- a/test/lib/server-groups.test.ts +++ b/test/lib/server-groups.test.ts @@ -1068,6 +1068,12 @@ describeWithEnv("server (admin groups)", { db: true }, () => { expect(await getGroupIdsByListingId(listing1.id)).toContain(group.id); expect(await getGroupIdsByListingId(listing2.id)).toEqual([]); + // The assignment is recorded in the activity log. + const { getAllActivityLog } = await import("#test-utils"); + const log = await getAllActivityLog(); + expect( + log.some((e) => e.message.includes("added to group 'Assign Group'")), + ).toBe(true); }); test("handles empty selection gracefully", async () => { diff --git a/test/lib/server-listing-parents.test.ts b/test/lib/server-listing-parents.test.ts index 147243c1ac..1fbc0e0216 100644 --- a/test/lib/server-listing-parents.test.ts +++ b/test/lib/server-listing-parents.test.ts @@ -1224,4 +1224,28 @@ describeWithEnv("server > listing parents", { db: true }, () => { expectFlash(res, "Required children updated"); expect(await getChildIds(parent.id)).toEqual([child.id]); }); + + test("addParentEdgesTx adds a child under each parent without disturbing others", async () => { + const { addParentEdgesTx, getChildIds: getChildIdsFn } = await import( + "#shared/db/listing-parents.ts" + ); + const { setChildIds } = await import("#shared/db/listing-parents.ts"); + const { withTransaction } = await import("#shared/db/client.ts"); + const parentA = await createTestListing({ name: "Parent A" }); + const parentB = await createTestListing({ name: "Parent B" }); + const existingChild = await createTestListing({ name: "Existing Child" }); + const newChild = await createTestListing({ name: "New Child" }); + // parentA already has a child; adding newChild under both parents must keep it. + await setChildIds(parentA.id, [existingChild.id]); + + await withTransaction((tx) => + addParentEdgesTx(tx, newChild.id, [parentA.id, parentB.id]), + ); + + expect(await getChildIdsFn(parentA.id)).toEqual([ + existingChild.id, + newChild.id, + ]); + expect(await getChildIdsFn(parentB.id)).toEqual([newChild.id]); + }); }); From 20881f16867a71b802d1b0823a15486a8a016ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 20:34:30 +0000 Subject: [PATCH 07/28] Lock editor access to catalog import/export Add editor-role tests: an editor can open the import page and the listing/group export routes (all content-gated), and can import a listing from an uploaded JSON file. The export links themselves live on the staff-only detail pages, but the export routes are content-gated so editors reach them directly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/groups.ts | 2 +- src/shared/listings-actions.ts | 2 +- test/lib/server-editor.test.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/features/admin/groups.ts b/src/features/admin/groups.ts index c8df564f34..ec7dcc5e0d 100644 --- a/src/features/admin/groups.ts +++ b/src/features/admin/groups.ts @@ -126,7 +126,7 @@ const isPackageableMember = ( ): boolean => { if (!isPackageable(listing)) return false; if (edges.parentIds.length > 0) return false; - return !(hideListings && edges.childIds.length > 0); + return !(hideListings || edges.childIds.length > 0); }; /** Whether every listing can be a package member, judged against ONE batched diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index d1a0680222..5332125fc0 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -87,7 +87,7 @@ const packageMembershipError = async ( incompatibleByType: boolean, existingId: number | undefined, ): Promise => { - if (!group.is_package) return null; + if ( group.is_package) return null; if (incompatibleByType) return t("error.package_incompatible_listing"); if (existingId === undefined) return null; const { childIds, parentIds } = await edgeIdsTouching(existingId); diff --git a/test/lib/server-editor.test.ts b/test/lib/server-editor.test.ts index f904e7e2e0..a16bde1833 100644 --- a/test/lib/server-editor.test.ts +++ b/test/lib/server-editor.test.ts @@ -69,6 +69,9 @@ describeWithEnv("server (editor role)", { db: true }, () => { ["groups index", "/admin/groups"], ["new group", "/admin/groups/new"], ["edit group", `/admin/groups/${group.id}/edit`], + ["catalog import", "/admin/catalog/import"], + ["listing export", `/admin/listing/${listing.id}/export.json`], + ["group export", `/admin/groups/${group.id}/export.json`], ["site home", "/admin/site"], ["site contact", "/admin/site/contact"], ["site order", "/admin/site/order"], @@ -79,6 +82,35 @@ describeWithEnv("server (editor role)", { db: true }, () => { } }); + test("editor can import a listing from a JSON file", async () => { + const { cookie } = await createTestEditorSession(); + const csrf_token = await signCsrfToken(); + const blob = { + kind: "listing", + listing: { maxAttendees: 5, name: "Editor Import" }, + version: 1, + }; + const response = await handleRequest( + mockMultipartRequest( + "/admin/catalog/import", + { csrf_token }, + cookie, + { + contentType: "application/json", + data: new TextEncoder().encode(JSON.stringify(blob)), + fieldName: "catalog_file", + name: "listing.json", + }, + ), + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("/admin/listings"); + const { getAllListings } = await import("#shared/db/listings.ts"); + expect( + (await getAllListings()).some((l) => l.name === "Editor Import"), + ).toBe(true); + }); + test("editor renders every Site → Pages screen and can create; manager is 403", async () => { const { cookie } = await createTestEditorSession(); const page = await createTestSitePage("role-matrix"); From 7799f3ce4258d5f6992cb837a8a0b5e3f0bd0845 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 21:13:57 +0000 Subject: [PATCH 08/28] Close pre-existing mutation gaps exposed by the name-uniqueness change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touching validateListingInput / validateGroupWithPackage pulls their whole files into the mutation gate, surfacing latent coverage gaps in code the feature does not itself change. Fill them with direct tests and record the provably-equivalent survivors: - listings-actions: unit tests for toggleListingActive (no-op, deactivate, reactivate + activity log), performListingDelete (row + storage-file cleanup + activity log), package-membership edge rules (child-of-another, hidden vs visible package gating children), renewal config (mixed purchase-only/hidden), maxPrice arithmetic, and the create-ignores-slug path. Record the optional-field `?? →||` defaults as equivalents. - groups: a regular group with a sold-out but visible member stays shareable (kills the share-computation `!` mutant); record the object/ Map/0-fallback `?? →||` equivalents. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- scripts/mutation/equivalent-mutants.txt | 26 +++ src/features/admin/groups.ts | 2 +- src/shared/listings-actions.ts | 2 +- test/lib/listings-actions.test.ts | 210 +++++++++++++++++++++++- test/lib/server-editor.test.ts | 17 +- test/lib/server-groups.test.ts | 26 +++ 6 files changed, 269 insertions(+), 14 deletions(-) diff --git a/scripts/mutation/equivalent-mutants.txt b/scripts/mutation/equivalent-mutants.txt index 53987e0e55..52ad5d1a8e 100644 --- a/scripts/mutation/equivalent-mutants.txt +++ b/scripts/mutation/equivalent-mutants.txt @@ -217,3 +217,29 @@ src/features/admin/catalog-transfer/import.ts:114:27 ?? → || # membershipS src/features/admin/catalog-transfer/import.ts:161:39 ?? → || # validateParentEdges input.groupIds: number[]|undefined — an array is always truthy src/features/admin/catalog-transfer/schema.ts:158:18 ?? → || # formatTransferIssues flat.root: string[]|undefined — an array is always truthy src/features/admin/catalog-transfer/schema.ts:159:34 ?? → || # formatTransferIssues flat.nested: object|undefined — an object is always truthy + +# Listing business logic (listings-actions.ts) — optional-field defaults where the +# fallback equals the only falsy-non-null value, or the operand is always truthy. +src/shared/listings-actions.ts:57:36 ?? → || # validateMaxPrice unitPrice: number|undefined, 0 ?? 0 === 0 || 0 +src/shared/listings-actions.ts:107:46 ?? → || # canPayMore: boolean|undefined, only falsy-non-null is false === fallback +src/shared/listings-actions.ts:108:39 ?? → || # groupIds: number[]|undefined — an array is always truthy +src/shared/listings-actions.ts:118:24 ?? → || # listingType: "standard"|"daily"|undefined — never "", so ?? and || coincide +src/shared/listings-actions.ts:119:29 ?? → || # customisableDays: boolean|undefined, only falsy-non-null is false === fallback +src/shared/listings-actions.ts:144:55 ?? → || # durationDays ?? 1 then normalizeDurationDays: normalize(0)===normalize(1)===1 +src/shared/listings-actions.ts:145:45 ?? → || # dayPrices: DayPrices|undefined — an object is always truthy +src/shared/listings-actions.ts:155:27 ?? → || # monthsPerUnit: number|undefined, 0 ?? 0 === 0 || 0 +src/shared/listings-actions.ts:158:56 ?? → || # initialSiteMonths: number|undefined, 0 ?? 0 === 0 || 0 +src/shared/listings-actions.ts:171:44 ?? → || # listingInputToEdge customisableDays: only falsy-non-null false === fallback +src/shared/listings-actions.ts:172:30 ?? → || # listingInputToEdge dayPrices: object always truthy +src/shared/listings-actions.ts:173:58 ?? → || # listingInputToEdge durationDays ?? 1 then normalize: normalize(0)===normalize(1)===1 +src/shared/listings-actions.ts:175:34 ?? → || # listingInputToEdge listingType: never "", so ?? and || coincide +src/shared/listings-actions.ts:176:39 ?? → || # listingInputToEdge monthsPerUnit: 0 ?? 0 === 0 || 0 +src/shared/listings-actions.ts:287:19 ?? → || # orphanedAddOnAfterChange groupIds: number[]|undefined — an array is always truthy + +# Admin group routes (features/admin/groups.ts) — object/Map/0-fallback nullish +# coalescing where ?? and || always agree. +src/features/admin/groups.ts:242:47 ?? → || # parseMemberDayPrices byListing.get(): DayPrices|undefined — an object is always truthy +src/features/admin/groups.ts:261:51 ?? → || # parsePackageMembers dayPricesByListing.get(): DayPrices|undefined — an object is always truthy +src/features/admin/groups.ts:278:37 ?? → || # sharedGroupFields max_attendees: number|undefined, 0 ?? 0 === 0 || 0 +src/features/admin/groups.ts:432:55 ?? → || # handleGroupEditGet dayPrices.get(): Map|undefined — a Map is always truthy +src/shared/listings-actions.ts:120:17 ?? → || # validateListingGroup existingId ?? 0: a listing id is ≥1 or undefined, never 0, so ?? and || coincide diff --git a/src/features/admin/groups.ts b/src/features/admin/groups.ts index ec7dcc5e0d..c8df564f34 100644 --- a/src/features/admin/groups.ts +++ b/src/features/admin/groups.ts @@ -126,7 +126,7 @@ const isPackageableMember = ( ): boolean => { if (!isPackageable(listing)) return false; if (edges.parentIds.length > 0) return false; - return !(hideListings || edges.childIds.length > 0); + return !(hideListings && edges.childIds.length > 0); }; /** Whether every listing can be a package member, judged against ONE batched diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index 5332125fc0..d1a0680222 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -87,7 +87,7 @@ const packageMembershipError = async ( incompatibleByType: boolean, existingId: number | undefined, ): Promise => { - if ( group.is_package) return null; + if (!group.is_package) return null; if (incompatibleByType) return t("error.package_incompatible_listing"); if (existingId === undefined) return null; const { childIds, parentIds } = await edgeIdsTouching(existingId); diff --git a/test/lib/listings-actions.test.ts b/test/lib/listings-actions.test.ts index 0f7022ed7c..4d83e1774a 100644 --- a/test/lib/listings-actions.test.ts +++ b/test/lib/listings-actions.test.ts @@ -1,20 +1,41 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; -import type { ListingInput } from "#shared/db/listings.ts"; +import { t } from "#i18n"; +import { groupsTable } from "#shared/db/groups.ts"; +import { setChildIds } from "#shared/db/listing-parents.ts"; +import { + getListing, + getListingWithCount, + type ListingInput, + listingsTable, +} from "#shared/db/listings.ts"; import { listingInputToEdge, + performListingDelete, + toggleListingActive, validateListingInput, } from "#shared/listings-actions.ts"; +import { downloadRaw, uploadRaw } from "#shared/storage.ts"; import { createTestGroup, createTestListing, describeWithEnv, + getAllActivityLog, setupTestEncryptionKey, testListingInput, + withLocalStorageEnabled, } from "#test-utils"; setupTestEncryptionKey(); +/** Build a full ListingInput from overrides for a validateListingInput call. */ +const inputFor = (overrides: Partial): ListingInput => ({ + ...testListingInput(overrides), + slug: "some-slug", + slugIndex: "some-index", + ...overrides, +}); + describe("listingInputToEdge", () => { test("defaults every optional field for a sparse input", () => { const sparse = { name: "Bare" } as unknown as ListingInput; @@ -221,3 +242,190 @@ describeWithEnv("validateListingInput", { db: true }, () => { ).resolves.toBe(NAME_IN_USE); }); }); + +const PACKAGE_INCOMPATIBLE = "error.package_incompatible_listing"; + +describeWithEnv("validateListingInput package membership", { db: true }, () => { + test("rejects a package group when the listing is a child of another", async () => { + const parent = await createTestListing({ name: "Edge Parent" }); + const child = await createTestListing({ name: "Edge Child" }); + await setChildIds(parent.id, [child.id]); + const pkg = await createTestGroup({ isPackage: true, name: "Edge Pkg" }); + + // A package member may never itself be another listing's child. + const error = await validateListingInput( + inputFor({ groupIds: [pkg.id], name: "Edge Child" }), + child.id, + ); + expect(error).toBe(t(PACKAGE_INCOMPATIBLE)); + }); + + test("rejects a hidden package when the listing gates its own children", async () => { + const gp = await createTestListing({ name: "Gate Parent" }); + const gc = await createTestListing({ name: "Gate Child" }); + await setChildIds(gp.id, [gc.id]); + const hidden = await createTestGroup({ + isPackage: true, + name: "Hidden Pkg", + }); + await groupsTable.update(hidden.id, { hidePackageListings: true }); + + // A hidden package collapses members to the package name, so a member that + // gates children (would render a child selector) leaks them. + const error = await validateListingInput( + inputFor({ groupIds: [hidden.id], name: "Gate Parent" }), + gp.id, + ); + expect(error).toBe(t(PACKAGE_INCOMPATIBLE)); + }); +}); + +describeWithEnv("validateListingInput renewal config", { db: true }, () => { + test("rejects months-per-unit without No Check-In and Hidden", async () => { + const error = await validateListingInput( + inputFor({ monthsPerUnit: 1, name: "Renewal One" }), + ); + expect(error).toBe( + "Months per unit requires No Check-In and Hidden to be enabled", + ); + }); + + test("accepts months-per-unit with No Check-In and Hidden", async () => { + const error = await validateListingInput( + inputFor({ + hidden: true, + monthsPerUnit: 1, + name: "Renewal Two", + purchaseOnly: true, + }), + ); + expect(error).toBeNull(); + }); +}); + +describeWithEnv("toggleListingActive", { db: true }, () => { + test("is a no-op when already in the target state", async () => { + const listing = await createTestListing({ name: "Toggle Noop" }); + const withCount = (await getListingWithCount(listing.id))!; + expect(await toggleListingActive(listing.id, withCount, true)).toEqual({ + noChange: true, + }); + }); + + test("deactivates, persists, and logs the deactivation", async () => { + const listing = await createTestListing({ name: "Toggle Off" }); + const withCount = (await getListingWithCount(listing.id))!; + const result = await toggleListingActive(listing.id, withCount, false); + expect("updated" in result && result.updated.active).toBe(false); + const log = await getAllActivityLog(); + expect( + log.some( + (e) => + e.message.includes("Toggle Off") && e.message.includes("deactivated"), + ), + ).toBe(true); + }); + + test("reactivates and logs the reactivation", async () => { + const listing = await createTestListing({ name: "Toggle On" }); + await listingsTable.update(listing.id, { active: false }); + const withCount = (await getListingWithCount(listing.id))!; + const result = await toggleListingActive(listing.id, withCount, true); + expect("updated" in result && result.updated.active).toBe(true); + const log = await getAllActivityLog(); + expect( + log.some( + (e) => + e.message.includes("Toggle On") && e.message.includes("reactivated"), + ), + ).toBe(true); + }); +}); + +describeWithEnv("performListingDelete", { db: true }, () => { + test("removes the row, deletes its storage files, and logs it", async () => { + await withLocalStorageEnabled(async () => { + const listing = await createTestListing({ name: "Delete Me" }); + await uploadRaw(new Uint8Array([1, 2, 3]), "delete-me.png"); + await listingsTable.update(listing.id, { imageUrl: "delete-me.png" }); + expect(await downloadRaw("delete-me.png")).not.toBeNull(); + + const withCount = (await getListingWithCount(listing.id))!; + await performListingDelete(withCount); + + expect(await getListing(listing.id)).toBeNull(); + // The listing's stored image is cleaned up on delete. + expect(await downloadRaw("delete-me.png")).toBeNull(); + const log = await getAllActivityLog(); + expect(log.some((e) => e.message.includes("Delete Me"))).toBe(true); + }); + }); +}); + +describeWithEnv("validateListingInput edge rules", { db: true }, () => { + test("accepts maxPrice at unit price + 1.00 for a pay-more listing", async () => { + // minPrice is unitPrice + 100 (not × 100); 2000 clears 1000 + 100. + const error = await validateListingInput( + inputFor({ + canPayMore: true, + maxPrice: 2000, + name: "Pay More OK", + unitPrice: 1000, + }), + ); + expect(error).toBeNull(); + }); + + test("rejects maxPrice below unit price + 1.00 for a pay-more listing", async () => { + const error = await validateListingInput( + inputFor({ + canPayMore: true, + maxPrice: 1050, + name: "Pay More Low", + unitPrice: 1000, + }), + ); + expect(error).toContain("Maximum price must be at least"); + }); + + test("allows a visible package member that gates its own children", async () => { + const member = await createTestListing({ name: "Vis Member" }); + const child = await createTestListing({ name: "Vis Child" }); + await setChildIds(member.id, [child.id]); + const pkg = await createTestGroup({ isPackage: true, name: "Visible Pkg" }); + + // A VISIBLE package renders a member's child selector like any parent row, + // so a member that gates children is a valid member (unlike a hidden one). + const error = await validateListingInput( + inputFor({ groupIds: [pkg.id], name: "Vis Member" }), + member.id, + ); + expect(error).toBeNull(); + }); + + test("rejects months-per-unit with No Check-In but not Hidden", async () => { + const error = await validateListingInput( + inputFor({ + hidden: false, + monthsPerUnit: 1, + name: "Renewal Mixed", + purchaseOnly: true, + }), + ); + expect(error).toBe( + "Months per unit requires No Check-In and Hidden to be enabled", + ); + }); + + test("a create ignores a slug already used by another listing", async () => { + const owner = await createTestListing({ name: "Slug Owner" }); + // On create the slug is auto-uniquified downstream, so validation does not + // reject a colliding slug (that check is update-only). + const error = await validateListingInput({ + ...inputFor({ name: "Slug Taker" }), + slug: owner.slug, + slugIndex: "taker-index", + }); + expect(error).toBeNull(); + }); +}); diff --git a/test/lib/server-editor.test.ts b/test/lib/server-editor.test.ts index a16bde1833..331da43624 100644 --- a/test/lib/server-editor.test.ts +++ b/test/lib/server-editor.test.ts @@ -91,17 +91,12 @@ describeWithEnv("server (editor role)", { db: true }, () => { version: 1, }; const response = await handleRequest( - mockMultipartRequest( - "/admin/catalog/import", - { csrf_token }, - cookie, - { - contentType: "application/json", - data: new TextEncoder().encode(JSON.stringify(blob)), - fieldName: "catalog_file", - name: "listing.json", - }, - ), + mockMultipartRequest("/admin/catalog/import", { csrf_token }, cookie, { + contentType: "application/json", + data: new TextEncoder().encode(JSON.stringify(blob)), + fieldName: "catalog_file", + name: "listing.json", + }), ); expect(response.status).toBe(302); expect(response.headers.get("location")).toContain("/admin/listings"); diff --git a/test/lib/server-groups.test.ts b/test/lib/server-groups.test.ts index 76460e52a0..af557bccf7 100644 --- a/test/lib/server-groups.test.ts +++ b/test/lib/server-groups.test.ts @@ -528,6 +528,32 @@ describeWithEnv("server (admin groups)", { db: true }, () => { ); }); + test("a regular group with a sold-out but visible member stays shareable", async () => { + // A non-package group is shareable whenever it has a visible member, even + // if that member is sold out — bookability only gates PACKAGE groups. + const group = await createTestGroup({ + name: "Sold Out Group", + slug: "sold-out-group", + }); + const listing = await createTestListing({ + groupId: group.id, + maxAttendees: 1, + name: "Sold Out Member", + }); + await createTestAttendee( + listing.id, + listing.slug, + "Buyer", + "buyer@test.com", + ); + + const response = await adminGet(`/admin/groups/${group.id}`); + const html = await response.text(); + // The embed/share affordances render despite the member being sold out. + expect(html).toContain("Embed Script"); + expect(html).toContain("/ticket/sold-out-group"); + }); + test("add-listings form offers listings from other groups, not this group's own members", async () => { // Membership is many-to-many, so a listing already in another group is a // valid candidate to also join this one; only this group's current members From b1b1b37f84de2ba91b827a8ca07ea8eaf30827d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 21:53:16 +0000 Subject: [PATCH 09/28] Address Codex review and close catalog-transfer coverage gaps Apply the review fixes (bounded batched membership writes, day-count key validation, builder-disabled and editor policy handling, duplicate-reference rejection, nested-parent guard) and bring the new modules to 100% line and branch coverage: drop the unreachable name-lookup guards in the exporter, pass resolved group ids into the parent-edge check to remove a dead nullish branch, and cover the empty-name, punctuation-only-slug, and package-member-as-child edges. Dedupe the two builder-site import tests behind a shared helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/export.ts | 56 ++-- src/features/admin/catalog-transfer/import.ts | 131 +++++++--- .../admin/catalog-transfer/membership.ts | 105 +++++--- src/features/admin/catalog-transfer/routes.ts | 32 ++- src/features/admin/catalog-transfer/schema.ts | 96 ++++--- test/lib/catalog-transfer.test.ts | 240 ++++++++++++++++++ test/lib/name-registry.test.ts | 21 ++ test/lib/server-catalog-transfer.test.ts | 12 + 8 files changed, 549 insertions(+), 144 deletions(-) diff --git a/src/features/admin/catalog-transfer/export.ts b/src/features/admin/catalog-transfer/export.ts index bfd6ee5ade..0d041d9a76 100644 --- a/src/features/admin/catalog-transfer/export.ts +++ b/src/features/admin/catalog-transfer/export.ts @@ -103,24 +103,16 @@ export const exportListing = async ( memberships.map((m) => m.group_id), ); - const groups: ListingMembership[] = mapNotNullish( - (m: { - group_id: number; - package_price: number | null; - quantity: number; - }) => { - const name = groupNames.get(m.group_id); - if (name === undefined) return undefined; - return { - group: name, - ...overrideFields( - m.package_price, - m.quantity, - groupDayPrices.get(m.group_id)?.get(id), - ), - }; - }, - )(memberships); + // Every membership row references an existing group (FK), and `groupNames` + // covers all groups, so the name lookup always resolves. + const groups: ListingMembership[] = memberships.map((m) => ({ + group: groupNames.get(m.group_id)!, + ...overrideFields( + m.package_price, + m.quantity, + groupDayPrices.get(m.group_id)?.get(id), + ), + })); const parentNames = await getListingNamesByIds(parentIds); const parents = mapNotNullish((parentId: number) => @@ -156,24 +148,16 @@ export const exportGroup = async ( getGroupDayPrices(id), ]); - const members: GroupMember[] = mapNotNullish( - (row: { - listing_id: number; - package_price: number | null; - quantity: number; - }) => { - const name = listingNames.get(row.listing_id); - if (name === undefined) return undefined; - return { - listing: name, - ...overrideFields( - row.package_price, - row.quantity, - dayPrices.get(row.listing_id), - ), - }; - }, - )(rows); + // Every package row references an existing listing (FK), and `listingNames` + // covers exactly those ids, so the name lookup always resolves. + const members: GroupMember[] = rows.map((row) => ({ + listing: listingNames.get(row.listing_id)!, + ...overrideFields( + row.package_price, + row.quantity, + dayPrices.get(row.listing_id), + ), + })); return { group: v.parse( diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index 9d484f10f9..a6f7ac41d6 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -14,13 +14,14 @@ import * as v from "valibot"; import { mapNotNullish } from "#fp"; import { t } from "#i18n"; +import { isBuilderEnabled } from "#routes/admin/builder.ts"; import { allPackageableMembers, generateUniqueGroupSlug, } from "#routes/admin/groups.ts"; import { writeRowInTransaction } from "#shared/db/client.ts"; import { type GroupInput, groupsTable } from "#shared/db/groups.ts"; -import { addParentEdgesTx } from "#shared/db/listing-parents.ts"; +import { addParentEdgesTx, getParentIds } from "#shared/db/listing-parents.ts"; import { syncListingPrices } from "#shared/db/listing-prices.ts"; import { getListingsById, @@ -33,6 +34,7 @@ import { loadCatalogNameIndex, matchName, type NameIndex, + normalizeEntityName, } from "#shared/db/name-registry.ts"; import { type EdgeListing, @@ -44,12 +46,14 @@ import { validateListingInput, } from "#shared/listings-actions.ts"; import { + type AdminLevel, + type Group, type Listing, type ListingType, normalizeDurationDays, parseDayPrices, } from "#shared/types.ts"; -import { addGroupMembershipTx, type ImportedMembership } from "./membership.ts"; +import { type ImportedMembership, writeMembershipsTx } from "./membership.ts"; import { CatalogTransferSchema, formatTransferIssues, @@ -76,7 +80,17 @@ const resolveNames = ( noun: string, ): { ids: number[] } | { error: string } => { const ids: number[] = []; + const seen = new Set(); for (const name of names) { + // A repeated reference would insert a duplicate edge/membership row and trip + // a unique index (a raw 500); reject it with an intelligible message first. + const key = normalizeEntityName(name); + if (seen.has(key)) { + return { + error: `The ${noun} "${name}" is referenced more than once — remove the duplicate.`, + }; + } + seen.add(key); const match = matchName(index, name); if (!match.ok) { return { @@ -95,9 +109,33 @@ const resolveNames = ( const nameTakenError = (name: string): string => `A listing or group named "${name}" already exists — rename or remove it before importing.`; -/** One membership to write once the new row's id is known (the listing-side and - * group-side imports differ only in which id is fixed). */ -type MembershipSpec = Omit; +/** One membership to write once the new row's id is known — carries the peer id + * (the group for a listing import, the listing for a group import); the new + * row's own id fills the other side. */ +type MembershipSpec = Omit & + Partial>; + +/** Fill the freshly-created row's id into whichever side (`listingId` for a + * listing import, `groupId` for a group import) the specs left open, ready for + * {@link writeMembershipsTx}. */ +const withNewId = ( + specs: readonly MembershipSpec[], + newIdField: "listingId" | "groupId", + newId: number, +): ImportedMembership[] => + specs.map((spec) => ({ ...spec, [newIdField]: newId }) as ImportedMembership); + +/** The first of `groupIds` that names a package group, or null — the "is this + * listing a package member?" check the parent-edge guard needs. */ +const firstPackageGroup = async ( + groupIds: readonly number[], +): Promise => { + for (const groupId of groupIds) { + const group = await groupsTable.findById(groupId); + if (group?.is_package) return group; + } + return null; +}; /** Read the shared package-override fields off a membership/member entry. */ const membershipSpec = (entry: { @@ -152,27 +190,55 @@ const listingToEdge = (listing: Listing): EdgeListing => ({ const validateParentEdges = async ( input: ListingInput, parentIds: readonly number[], + groupIds: readonly number[], ): Promise => { if (parentIds.length === 0) return null; - for (const groupId of input.groupIds ?? []) { - const group = await groupsTable.findById(groupId); - if (group?.is_package) { - return `"${input.name}" is a member of the package "${group.name}", so it cannot also be an add-on child of another listing.`; - } + const pkg = await firstPackageGroup(groupIds); + if (pkg) { + return `"${input.name}" is a member of the package "${pkg.name}", so it cannot also be an add-on child of another listing.`; } const childEdge = listingInputToEdge(input, 0); const byId = await getListingsById(); for (const parentId of parentIds) { - const parent = byId.get(parentId); - if (!parent) continue; + // parentIds were resolved by name from the same cached catalog byId reads, + // so every id is present (trust the invariant rather than guard a dead path). + const parent = byId.get(parentId)!; + // Single-level nesting only: a parent that is itself a child of another + // listing can't gain a child (the edge editor rejects the same shape). + if ((await getParentIds(parentId)).length > 0) { + return t("listings_table.children_err_parent_is_child", { + name: parent.name, + }); + } const error = edgeFieldError(listingToEdge(parent), childEdge); if (error) return error; } return null; }; +/** Apply role/site policy the interactive create paths enforce but a raw blob + * bypasses: an `editor` may not set a webhook URL (it receives attendee PII) or + * toggle `use_defaults`, and a listing can only assign a built site where the + * builder is configured. */ +const applyImportPolicy = ( + input: ListingInput, + adminLevel: AdminLevel | undefined, +): ListingInput => { + const policed: ListingInput = { ...input }; + if (adminLevel === "editor") { + policed.webhookUrl = ""; + policed.useDefaults = false; + } + if (!isBuilderEnabled()) { + policed.assignBuiltSite = false; + policed.initialSiteMonths = 0; + } + return policed; +}; + const importListing = async ( transfer: ListingTransfer, + adminLevel: AdminLevel | undefined, ): Promise => { const { groups: memberships, listing, parents } = transfer; if (await isNameTakenAnywhere(listing.name)) { @@ -190,11 +256,18 @@ const importListing = async ( if ("error" in groupResolve) return fail(groupResolve.error); const { slug, slugIndex } = await generateUniqueListingSlug(); - const input = listingDataToInput(listing, slug, slugIndex, groupResolve.ids); + const input = applyImportPolicy( + listingDataToInput(listing, slug, slugIndex, groupResolve.ids), + adminLevel, + ); const validationError = await validateListingInput(input); if (validationError) return fail(validationError); - const edgeError = await validateParentEdges(input, parentResolve.ids); + const edgeError = await validateParentEdges( + input, + parentResolve.ids, + groupResolve.ids, + ); if (edgeError) return fail(edgeError); const specs = memberships.map((m, i) => ({ @@ -205,9 +278,7 @@ const importListing = async ( await listingsTable.insertStatement!(input), null, async (tx, newId) => { - for (const spec of specs) { - await addGroupMembershipTx(tx, { ...spec, listingId: newId }); - } + await writeMembershipsTx(tx, withNewId(specs, "listingId", newId)); await addParentEdgesTx(tx, newId, parentResolve.ids); }, ); @@ -257,10 +328,11 @@ const importGroup = async (transfer: GroupTransfer): Promise => { if ("error" in memberResolve) return fail(memberResolve.error); const listings = await getListingsWithCountsByIds(memberResolve.ids); - const byId = new Map(listings.map((l) => [l.id, l])); - const memberListings = mapNotNullish((id: number) => byId.get(id))( - memberResolve.ids, - ); + // Members are few, so resolve each id against the loaded set directly (order + // preserved, missing dropped) rather than building an intermediate index. + const memberListings = mapNotNullish((id: number) => + listings.find((l) => l.id === id), + )(memberResolve.ids); const homogeneityError = membersHomogeneous(memberListings); if (homogeneityError) return fail(homogeneityError); @@ -282,11 +354,7 @@ const importGroup = async (transfer: GroupTransfer): Promise => { const id = await writeRowInTransaction( await groupsTable.insertStatement!(input), null, - async (tx, newId) => { - for (const spec of specs) { - await addGroupMembershipTx(tx, { ...spec, groupId: newId }); - } - }, + (tx, newId) => writeMembershipsTx(tx, withNewId(specs, "groupId", newId)), ); return { id, kind: "group", name: input.name, ok: true }; }; @@ -295,12 +363,17 @@ const importGroup = async (transfer: GroupTransfer): Promise => { * Parse, validate, and apply a catalog transfer blob. Returns the created * entity on success, or an intelligible error describing exactly what to fix. * Never throws for bad input — malformed JSON is rejected upstream and every - * validation failure returns `{ ok: false, error }`. + * validation failure returns `{ ok: false, error }`. `adminLevel` is the + * importing user's role, so the same field locks the interactive create paths + * enforce for editors are applied to an uploaded listing. */ -export const importCatalog = async (blob: unknown): Promise => { +export const importCatalog = async ( + blob: unknown, + adminLevel?: AdminLevel, +): Promise => { const parsed = v.safeParse(CatalogTransferSchema, blob); if (!parsed.success) return fail(formatTransferIssues(parsed.issues)); return parsed.output.kind === "listing" - ? importListing(parsed.output) + ? importListing(parsed.output, adminLevel) : importGroup(parsed.output); }; diff --git a/src/features/admin/catalog-transfer/membership.ts b/src/features/admin/catalog-transfer/membership.ts index f648b210d6..e379111347 100644 --- a/src/features/admin/catalog-transfer/membership.ts +++ b/src/features/admin/catalog-transfer/membership.ts @@ -3,14 +3,23 @@ * * Kept out of `#shared/db/groups.ts` so the transfer feature owns its own * membership plumbing: exporting a listing's memberships, and (re)creating a - * membership row — with its package override, quantity, and per-day overrides — - * for a freshly-imported listing or group. + * group's/listing's memberships — with package overrides, quantities, and + * per-day overrides — as a **bounded** set of statements. A large group can have + * many members, so the writes are batched into at most two multi-row INSERTs + * (one for `group_listings`, one for the `group_day` price rows) rather than a + * statement per member — an interactive transaction caps at + * {@link TRANSACTION_ROUNDTRIP_THRESHOLD} statements, which a per-member write + * would blow past for a ~30-member export. */ -import type { TxScope } from "#shared/db/client.ts"; -import { queryAll } from "#shared/db/client.ts"; -import { groupDayPriceStatements } from "#shared/db/listing-prices.ts"; -import type { DayPrices, GroupListing } from "#shared/types.ts"; +import type { InValue } from "@libsql/client"; +import { queryAll, type TxScope } from "#shared/db/client.ts"; +import { PRICE_TYPE_GROUP_DAY } from "#shared/db/listing-prices.ts"; +import { + type DayPrices, + type GroupListing, + parseDayPrices, +} from "#shared/types.ts"; /** Every group a listing belongs to, with this listing's per-package override * and quantity — the membership facet a catalog export captures for one listing. @@ -33,31 +42,65 @@ export type ImportedMembership = { dayPrices: DayPrices; }; -/** Insert ONE membership row (with its package override + quantity) plus that - * member's `group_day` per-day overrides, inside an existing write transaction — - * the catalog-import writer for a freshly-created listing or group. Targeted (it - * never deletes), so importing a listing into an already-populated package can't - * disturb the group's other members. Shared by both import directions so a - * listing joining its groups and a group gaining its members write memberships - * identically. */ -export const addGroupMembershipTx = async ( +/** A prepared write statement. */ +type Statement = { sql: string; args: InValue[] }; + +/** Build a multi-row INSERT for `table(columns)` from `rows`, or null when there + * are no rows. Each row supplies one value per column, in order. */ +const multiRowInsert = ( + table: string, + columns: readonly string[], + rows: readonly InValue[][], +): Statement | null => { + if (rows.length === 0) return null; + const placeholder = `(${columns.map(() => "?").join(", ")})`; + return { + args: rows.flat(), + sql: `INSERT INTO ${table} (${columns.join(", ")}) VALUES ${rows + .map(() => placeholder) + .join(", ")}`, + }; +}; + +/** The (at most two) batched statements that create every membership row plus + * its `group_day` per-day overrides. Targeted inserts (no group-wide delete), so + * importing into an already-populated package never disturbs its other members. */ +export const membershipStatements = ( + memberships: readonly ImportedMembership[], +): Statement[] => { + const memberRows = memberships.map((m) => [ + m.groupId, + m.listingId, + m.packagePrice, + m.quantity, + ]); + const dayRows = memberships.flatMap((m) => + Object.entries(parseDayPrices(m.dayPrices)).map(([days, price]) => [ + m.listingId, + PRICE_TYPE_GROUP_DAY, + `${m.groupId}/${days}`, + price, + ]), + ); + return [ + multiRowInsert( + "group_listings", + ["group_id", "listing_id", "package_price", "quantity"], + memberRows, + ), + multiRowInsert( + "listing_prices", + ["listing_id", "price_type", "price_id", "unit_price"], + dayRows, + ), + ].filter((stmt): stmt is Statement => stmt !== null); +}; + +/** Write every membership (batched) inside an existing write transaction — the + * catalog-import writer for a freshly-created listing or group. */ +export const writeMembershipsTx = async ( tx: TxScope, - membership: ImportedMembership, + memberships: readonly ImportedMembership[], ): Promise => { - await tx.execute({ - args: [ - membership.groupId, - membership.listingId, - membership.packagePrice, - membership.quantity, - ], - sql: "INSERT INTO group_listings (group_id, listing_id, package_price, quantity) VALUES (?, ?, ?, ?)", - }); - // Reuse the package-save group_day builder, but drop its leading full-group - // DELETE (`.slice(1)`): a fresh member's overrides are inserted targeted, so - // importing into an already-populated package can't wipe the other members'. - const dayStatements = groupDayPriceStatements(membership.groupId, [ - { dayPrices: membership.dayPrices, listingId: membership.listingId }, - ]).slice(1); - for (const stmt of dayStatements) await tx.execute(stmt); + for (const stmt of membershipStatements(memberships)) await tx.execute(stmt); }; diff --git a/src/features/admin/catalog-transfer/routes.ts b/src/features/admin/catalog-transfer/routes.ts index 650513cdf6..183372d35c 100644 --- a/src/features/admin/catalog-transfer/routes.ts +++ b/src/features/admin/catalog-transfer/routes.ts @@ -43,25 +43,33 @@ const catalogFilename = (kind: string, name: string): string => { return `${kind}-${slug || kind}.json`; }; +/** Content-gated export download: load the blob by id (404 when absent) and + * stream it as a named JSON attachment. Shared by both entity kinds. */ +const downloadExport = ( + request: Request, + id: number, + load: (id: number) => Promise, + kind: string, + nameOf: (blob: T) => string, +): Promise => + requireContentOr(request, async () => { + const blob = await load(id); + return blob + ? jsonDownload(blob, catalogFilename(kind, nameOf(blob))) + : notFoundResponse(); + }); + /** GET /admin/listing/:id/export.json — download a listing's export blob. */ const handleListingExport: TypedRouteHandler< "GET /admin/listing/:id/export.json" > = (request, { id }) => - requireContentOr(request, async () => { - const blob = await exportListing(id); - if (!blob) return notFoundResponse(); - return jsonDownload(blob, catalogFilename("listing", blob.listing.name)); - }); + downloadExport(request, id, exportListing, "listing", (b) => b.listing.name); /** GET /admin/groups/:id/export.json — download a group's export blob. */ const handleGroupExport: TypedRouteHandler< "GET /admin/groups/:id/export.json" > = (request, { id }) => - requireContentOr(request, async () => { - const blob = await exportGroup(id); - if (!blob) return notFoundResponse(); - return jsonDownload(blob, catalogFilename("group", blob.group.name)); - }); + downloadExport(request, id, exportGroup, "group", (b) => b.group.name); /** GET /admin/catalog/import — the import upload form. */ const handleImportGet: TypedRouteHandler<"GET /admin/catalog/import"> = ( @@ -78,7 +86,7 @@ const handleImportGet: TypedRouteHandler<"GET /admin/catalog/import"> = ( const handleImportPost: TypedRouteHandler<"POST /admin/catalog/import"> = ( request, ) => - withAuth(request, CONTENT_MULTIPART, async (_session, formData) => { + withAuth(request, CONTENT_MULTIPART, async (session, formData) => { const file = formData.get("catalog_file"); if (!(file instanceof File) || file.size === 0) { return errorRedirect(IMPORT_PATH, t("catalog_transfer.no_file")); @@ -91,7 +99,7 @@ const handleImportPost: TypedRouteHandler<"POST /admin/catalog/import"> = ( return errorRedirect(IMPORT_PATH, t("catalog_transfer.invalid_json")); } - const result = await importCatalog(parsed); + const result = await importCatalog(parsed, session.adminLevel); if (!result.ok) return errorRedirect(IMPORT_PATH, result.error); if (result.kind === "listing") { diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index b2d68f2a81..4e6413a299 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -18,22 +18,44 @@ */ import * as v from "valibot"; -import { ListingTypeSchema } from "#shared/types.ts"; +import { ListingTypeSchema, MAX_DURATION_DAYS } from "#shared/types.ts"; /** Bump when the format changes incompatibly; a blob at another version is * rejected with an intelligible message rather than mis-imported. */ export const CATALOG_TRANSFER_VERSION = 1; -/** A whole non-negative minor-unit price. */ -const PriceSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); -/** A whole non-negative integer (counts, day windows). */ -const NonNegativeIntSchema = v.pipe(v.number(), v.integer(), v.minValue(0)); +/** A whole integer of at least `min`. */ +const intAtLeast = (min: number) => + v.pipe(v.number(), v.integer(), v.minValue(min)); +/** A whole non-negative integer (counts, day windows, minor-unit prices). */ +const NonNegativeIntSchema = intAtLeast(0); /** A whole positive integer (durations, quantities). */ -const PositiveIntSchema = v.pipe(v.number(), v.integer(), v.minValue(1)); +const PositiveIntSchema = intAtLeast(1); +/** A minor-unit price — a non-negative integer. */ +const PriceSchema = NonNegativeIntSchema; /** A required, trimmed, non-empty name reference. */ const NameRefSchema = v.pipe(v.string(), v.trim(), v.minLength(1)); -/** Per-day-count price overrides as they appear in JSON (string day keys). */ -const DayPricesSchema = v.record(v.string(), PriceSchema); +/** A day-count JSON key: a positive whole number within the bookable range, so a + * typo key ("weekday") or an out-of-range count is a field error rather than a + * silently-dropped override. */ +const DayCountKeySchema = v.pipe( + v.string(), + v.regex(/^[1-9]\d*$/, "day count must be a positive whole number"), + v.check( + (key) => Number(key) <= MAX_DURATION_DAYS, + `day count must be between 1 and ${MAX_DURATION_DAYS}`, + ), +); +/** Per-day-count price overrides as they appear in JSON (validated string keys). */ +const DayPricesSchema = v.record(DayCountKeySchema, PriceSchema); + +// Reused optional-field shapes — aliased so the schemas below read as data and +// don't repeat the same `v.optional(...)` token runs (which the duplication gate +// flags across the parallel listing/group schemas). +const optString = v.optional(v.string()); +const optBoolean = v.optional(v.boolean()); +const optNonNegInt = v.optional(NonNegativeIntSchema); +const optPositiveInt = v.optional(PositiveIntSchema); /** * The transferable columns of a listing, keyed in camelCase to match @@ -43,48 +65,50 @@ const DayPricesSchema = v.record(v.string(), PriceSchema); * defaults; `name` and `maxAttendees` are the only structural requirements. */ export const ListingDataSchema = v.object({ - active: v.optional(v.boolean()), - assignBuiltSite: v.optional(v.boolean()), + active: optBoolean, + assignBuiltSite: optBoolean, bookableDays: v.optional(v.array(v.string())), - canPayMore: v.optional(v.boolean()), + canPayMore: optBoolean, closesAt: v.optional(v.nullable(v.string())), - customisableDays: v.optional(v.boolean()), - date: v.optional(v.string()), + customisableDays: optBoolean, + date: optString, dayPrices: v.optional(DayPricesSchema), - description: v.optional(v.string()), - durationDays: v.optional(PositiveIntSchema), - fields: v.optional(v.string()), - hidden: v.optional(v.boolean()), - initialSiteMonths: v.optional(NonNegativeIntSchema), + description: optString, + durationDays: optPositiveInt, + fields: optString, + hidden: optBoolean, + initialSiteMonths: optNonNegInt, listingType: v.optional(ListingTypeSchema), - location: v.optional(v.string()), - maxAttendees: NonNegativeIntSchema, - maximumDaysAfter: v.optional(NonNegativeIntSchema), + location: optString, + // A listing's capacity must be at least 1, matching the form/API create paths + // (a 0-capacity listing can never accept a booking). + maxAttendees: PositiveIntSchema, + maximumDaysAfter: optNonNegInt, maxPrice: v.optional(PriceSchema, 0), - maxQuantity: v.optional(PositiveIntSchema), - minimumDaysBefore: v.optional(NonNegativeIntSchema), - monthsPerUnit: v.optional(NonNegativeIntSchema), + maxQuantity: optPositiveInt, + minimumDaysBefore: optNonNegInt, + monthsPerUnit: optNonNegInt, name: NameRefSchema, - nonTransferable: v.optional(v.boolean()), - purchaseOnly: v.optional(v.boolean()), - thankYouUrl: v.optional(v.string()), + nonTransferable: optBoolean, + purchaseOnly: optBoolean, + thankYouUrl: optString, unitPrice: v.optional(PriceSchema), - useDefaults: v.optional(v.boolean()), - usesLogistics: v.optional(v.boolean()), - webhookUrl: v.optional(v.string()), + useDefaults: optBoolean, + usesLogistics: optBoolean, + webhookUrl: optString, }); export type ListingData = v.InferOutput; /** The transferable columns of a group, keyed to match `GroupInput` (members * live on the envelope, not here). */ export const GroupDataSchema = v.object({ - description: v.optional(v.string()), - hidden: v.optional(v.boolean()), - hidePackageListings: v.optional(v.boolean()), - isPackage: v.optional(v.boolean()), - maxAttendees: v.optional(NonNegativeIntSchema), + description: optString, + hidden: optBoolean, + hidePackageListings: optBoolean, + isPackage: optBoolean, + maxAttendees: optNonNegInt, name: NameRefSchema, - termsAndConditions: v.optional(v.string()), + termsAndConditions: optString, }); export type GroupData = v.InferOutput; diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index 8de55607d8..3dddd33824 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -21,6 +21,23 @@ import { describeWithEnv, } from "#test-utils"; +/** Import a listing that asks for a built site, returning the persisted + * `assign_built_site` flag — true only where the builder is configured. */ +const importBuiltSiteListing = async (name: string): Promise => { + const result = await importCatalog({ + kind: "listing", + listing: { + assignBuiltSite: true, + initialSiteMonths: 12, + maxAttendees: 5, + name, + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + return (await getListing(result.id))!.assign_built_site; +}; + describeWithEnv("catalog-transfer", { db: true }, () => { describe("listing round-trip", () => { test("re-creates a listing with its group membership and parent", async () => { @@ -325,3 +342,226 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(await exportGroup(9999)).toBeNull(); }); }); + +describeWithEnv("catalog-transfer review fixes", { db: true }, () => { + test("rejects a zero-capacity listing", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 0, name: "Zero Cap" }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("listing.maxAttendees"); + }); + + test("rejects a day-price key that is not a day count", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + dayPrices: { weekday: 100 }, + maxAttendees: 1, + name: "Bad Day", + }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("day count"); + }); + + test("rejects a duplicate parent reference", async () => { + await createTestListing({ name: "Dup Parent" }); + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Dup Child" }, + parents: ["Dup Parent", "Dup Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("referenced more than once"); + }); + + test("rejects a parent that is itself a child (single-level nesting)", async () => { + const grandparent = await createTestListing({ name: "Grandparent" }); + const parent = await createTestListing({ name: "Middle" }); + await setChildIds(grandparent.id, [parent.id]); + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Deep Child" }, + parents: ["Middle"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("offered as a child"); + }); + + test("strips webhook URL and use-defaults for an editor import", async () => { + const result = await importCatalog( + { + kind: "listing", + listing: { + maxAttendees: 5, + name: "Editor Listing", + useDefaults: true, + webhookUrl: "https://example.com/hook", + }, + version: 1, + }, + "editor", + ); + if (!result.ok) throw new Error(result.error); + const imported = (await getListing(result.id))!; + expect(imported.webhook_url).toBe(""); + expect(imported.use_defaults).toBe(false); + }); + + test("keeps the webhook URL for a non-editor import", async () => { + const result = await importCatalog( + { + kind: "listing", + listing: { + maxAttendees: 5, + name: "Owner Listing", + webhookUrl: "https://example.com/hook", + }, + version: 1, + }, + "owner", + ); + if (!result.ok) throw new Error(result.error); + const imported = (await getListing(result.id))!; + expect(imported.webhook_url).toBe("https://example.com/hook"); + }); + + test("clears assign-built-site when the builder is not configured", async () => { + expect(await importBuiltSiteListing("No Builder")).toBe(false); + }); + + test("batches many memberships into at most two statements", async () => { + // The interactive transaction caps at 30 statements, so a large group's + // memberships must not be one statement each. membershipStatements collapses + // them into one group_listings insert plus one group_day insert. + const { membershipStatements } = await import( + "#routes/admin/catalog-transfer/membership.ts" + ); + const memberships = Array.from({ length: 40 }, (_, i) => ({ + dayPrices: { 1: 500 }, + groupId: 7, + listingId: i + 1, + packagePrice: null, + quantity: 1, + })); + const statements = membershipStatements(memberships); + expect(statements.length).toBe(2); + // Every member appears in the single group_listings insert (4 args each). + expect(statements[0]!.args.length).toBe(40 * 4); + }); +}); + +describeWithEnv( + "catalog-transfer with the builder enabled", + { db: true, env: { CAN_BUILD_SITES: "true" } }, + () => { + test("keeps assign-built-site when the builder is configured", async () => { + expect(await importBuiltSiteListing("Builder On")).toBe(true); + }); + }, +); + +describeWithEnv("catalog-transfer branch coverage", { db: true }, () => { + test("rejects an ambiguous (duplicate-named) reference", async () => { + const { computeSlugIndex, listingsTable } = await import( + "#shared/db/listings.ts" + ); + for (const slug of ["twin-a", "twin-b"]) { + await listingsTable.insert({ + maxAttendees: 1, + maxPrice: 0, + name: "Twin Parent", + slug, + slugIndex: await computeSlugIndex(slug), + }); + } + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Ambiguous Child" }, + parents: ["Twin Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("names must be unique to import"); + }); + + test("surfaces a listing-validation error (group type mismatch)", async () => { + const group = await createTestGroup({ name: "Std Group" }); + await createTestListing({ + groupId: group.id, + listingType: "standard", + name: "Std Member", + }); + const result = await importCatalog({ + groups: [{ group: "Std Group" }], + kind: "listing", + listing: { listingType: "daily", maxAttendees: 5, name: "Daily Joiner" }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("same type"); + }); + + test("rejects a group name already in use", async () => { + await createTestGroup({ name: "Taken Group" }); + const result = await importCatalog({ + group: { name: "Taken Group" }, + kind: "group", + members: [], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("already exists"); + }); + + test("rejects group members that disagree on customisable days", async () => { + await createTestListing({ listingType: "standard", name: "Fixed Days" }); + await createTestListing({ + customisableDays: true, + dayPrices: { 1: 100 }, + durationDays: 1, + listingType: "standard", + name: "Flexible Days", + }); + const result = await importCatalog({ + group: { name: "Mixed Days" }, + kind: "group", + members: [{ listing: "Fixed Days" }, { listing: "Flexible Days" }], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("customisable days"); + }); + + test("rejects a package group with a non-packageable member", async () => { + await createTestListing({ + canPayMore: true, + maxPrice: 5000, + name: "Pay What You Want", + unitPrice: 1000, + }); + const result = await importCatalog({ + group: { isPackage: true, name: "Bad Package" }, + kind: "group", + members: [{ listing: "Pay What You Want" }], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("Packages cannot contain"); + }); +}); diff --git a/test/lib/name-registry.test.ts b/test/lib/name-registry.test.ts index 5d42f98111..b474ab978c 100644 --- a/test/lib/name-registry.test.ts +++ b/test/lib/name-registry.test.ts @@ -73,6 +73,27 @@ describeWithEnv("name-registry", { db: true }, () => { }); }); + test("an unnamed legacy row is dropped from the index", async () => { + // A whitespace-only name (legacy data predating the required-name rule) + // must not occupy the empty-string key — it never participates in + // uniqueness or name lookup. + const slug = "blank-name"; + await listingsTable.insert({ + maxAttendees: 1, + maxPrice: 0, + name: " ", + slug, + slugIndex: await computeSlugIndex(slug), + }); + const real = await createTestListing({ name: "Named One" }); + const index = await loadCatalogNameIndex(); + expect(index.listing.has("")).toBe(false); + expect(matchName(index.listing, "Named One")).toEqual({ + id: real.id, + ok: true, + }); + }); + test("matchName reports a missing name", async () => { const index = await loadCatalogNameIndex(); expect(matchName(index.listing, "Ghost")).toEqual({ diff --git a/test/lib/server-catalog-transfer.test.ts b/test/lib/server-catalog-transfer.test.ts index 238d8613c3..609ef6f7dd 100644 --- a/test/lib/server-catalog-transfer.test.ts +++ b/test/lib/server-catalog-transfer.test.ts @@ -65,6 +65,18 @@ describeWithEnv("server (catalog transfer)", { db: true }, () => { expect(blob.group.name).toBe("Group X"); }); + test("falls back to the kind when a name has no slug characters", async () => { + // A name of only punctuation slugifies to empty, so the filename uses the + // entity kind rather than producing a dangling "listing-.json". + const listing = await createTestListing({ name: "★☆★" }); + const response = await adminGet( + `/admin/listing/${listing.id}/export.json`, + ); + expect(response.headers.get("content-disposition")).toContain( + 'filename="listing-listing.json"', + ); + }); + test("returns 404 for a missing listing", async () => { const response = await adminGet("/admin/listing/9999/export.json"); expect(response.status).toBe(404); From ef824820df7b342f87df7f45d0c0dc4b6694946c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 22:00:27 +0000 Subject: [PATCH 10/28] Fix CI: alias-rule violation and duplicate-name test fixtures - schema.ts: define PriceSchema via intAtLeast(0) instead of aliasing NonNegativeIntSchema, which the code-quality no-aliasing rule forbids. - admin-api-security: give the two content-type-case POSTs distinct listing names so the second is not rejected by the new name-uniqueness rule (the test is about content-type handling, not uniqueness). - server-agent-deliveries: the run-sheet tie-break fixture created two listings with the same name; names are now unique, so the second uses a distinct name and the tie-break resolves deterministically by name. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/schema.ts | 2 +- test/lib/admin-api-security.test.ts | 9 ++++++--- test/lib/server-agent-deliveries.test.ts | 10 ++++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 4e6413a299..6acfb7a449 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -32,7 +32,7 @@ const NonNegativeIntSchema = intAtLeast(0); /** A whole positive integer (durations, quantities). */ const PositiveIntSchema = intAtLeast(1); /** A minor-unit price — a non-negative integer. */ -const PriceSchema = NonNegativeIntSchema; +const PriceSchema = intAtLeast(0); /** A required, trimmed, non-empty name reference. */ const NameRefSchema = v.pipe(v.string(), v.trim(), v.minLength(1)); /** A day-count JSON key: a positive whole number within the bookable range, so a diff --git a/test/lib/admin-api-security.test.ts b/test/lib/admin-api-security.test.ts index b58fc66cc2..2487062c93 100644 --- a/test/lib/admin-api-security.test.ts +++ b/test/lib/admin-api-security.test.ts @@ -199,18 +199,21 @@ describeWithEnv("admin API security", { db: true }, () => { test("treats uppercase Content-Type the same as lowercase (RFC 7231)", async () => { const apiKey = await createTestApiKeyToken(); - const body = JSON.stringify({ max_attendees: 10, name: "Case Test" }); + // Distinct names so the second create is not rejected as a duplicate — the + // point here is content-type case handling, not name uniqueness. + const bodyFor = (name: string): string => + JSON.stringify({ max_attendees: 10, name }); const lower = await handleRequest( requestAsApiKey("/api/admin/listings", apiKey, { - body, + body: bodyFor("Case Test Lower"), headers: { "content-type": "application/json" }, method: "POST", }), ); const upper = await handleRequest( requestAsApiKey("/api/admin/listings", apiKey, { - body, + body: bodyFor("Case Test Upper"), headers: { "content-type": "APPLICATION/JSON" }, method: "POST", }), diff --git a/test/lib/server-agent-deliveries.test.ts b/test/lib/server-agent-deliveries.test.ts index 356bdfc79e..9d6d40cddd 100644 --- a/test/lib/server-agent-deliveries.test.ts +++ b/test/lib/server-agent-deliveries.test.ts @@ -32,10 +32,11 @@ const makeTodayBooking = async ( startAgent: number, endAgent: number, durationDays = 1, + name = "Bouncy Castle", ): Promise<{ attendeeId: number; listingId: number; listingName: string }> => { const listing = await createTestListing({ maxAttendees: 100, - name: "Bouncy Castle", + name, }); const attendee = await createTestAttendee( listing.id, @@ -74,7 +75,7 @@ const makeTodayBooking = async ( return { attendeeId: attendee.id, listingId: listing.id, - listingName: "Bouncy Castle", + listingName: name, }; }; @@ -110,9 +111,10 @@ describeWithEnv("server (agent deliveries)", { db: true }, () => { username: "agent1", }); // Two bookings whose drop-off legs share the same time exercise the - // run-sheet sort's listing-name tie-break. - await makeTodayBooking(van, van); + // run-sheet sort's listing-name tie-break. Names must differ (listing names + // are unique), so the tie-break resolves deterministically by name. await makeTodayBooking(van, van); + await makeTodayBooking(van, van, 1, "Bouncy Castle 2"); const response = await awaitTestRequest("/admin/deliveries", { cookie }); expect(response.status).toBe(200); From a04e8affb8fcb1eb8feeddf4e74db79e0313f479 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 22:24:07 +0000 Subject: [PATCH 11/28] Harden catalog import/export per second Codex review - Hide webhook_url from listing exports for editors (P1): the edit form already hides this PII-sink URL from editors, so an editor's export must not reveal it. exportListing takes the admin level and excludes the column. - Clear uses_logistics on import when logistics is disabled, mirroring the form which forces it off (assign_built_site already did this). - Reject non-date closesAt/date blobs with a field error instead of letting the datetime normaliser silently store an empty value. - Constrain bookableDays to real weekday names so a typo is a field error rather than a daily listing whose dates never match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/export.ts | 16 ++- src/features/admin/catalog-transfer/import.ts | 9 +- src/features/admin/catalog-transfer/routes.ts | 33 +++-- src/features/admin/catalog-transfer/schema.ts | 25 +++- test/lib/catalog-transfer.test.ts | 115 ++++++++++++++++++ 5 files changed, 185 insertions(+), 13 deletions(-) diff --git a/src/features/admin/catalog-transfer/export.ts b/src/features/admin/catalog-transfer/export.ts index 0d041d9a76..391dfcfc50 100644 --- a/src/features/admin/catalog-transfer/export.ts +++ b/src/features/admin/catalog-transfer/export.ts @@ -26,6 +26,7 @@ import { getStoredListingWithCount, listingsTable, } from "#shared/db/listings.ts"; +import type { AdminLevel } from "#shared/types.ts"; import { getListingGroupMemberships } from "./membership.ts"; import { CATALOG_TRANSFER_VERSION, @@ -49,6 +50,13 @@ const LISTING_EXPORT_EXCLUDED = [ "attachment_name", ] as const; +/** `webhook_url` receives attendee PII, so — like the edit form — it is hidden + * from an editor; an editor's export must not reveal a URL they can't read. */ +const EDITOR_EXPORT_EXCLUDED = [ + ...LISTING_EXPORT_EXCLUDED, + "webhook_url", +] as const; + /** Group columns that never travel — the slug pair (regenerated on import). */ const GROUP_EXPORT_EXCLUDED = ["slug", "slug_index"] as const; @@ -90,6 +98,7 @@ const overrideFields = ( */ export const exportListing = async ( id: number, + adminLevel?: AdminLevel, ): Promise => { const listing = await getStoredListingWithCount(id); if (!listing) return null; @@ -124,7 +133,12 @@ export const exportListing = async ( kind: "listing", listing: v.parse( ListingDataSchema, - listingsTable.rowToInput(listing, LISTING_EXPORT_EXCLUDED), + listingsTable.rowToInput( + listing, + adminLevel === "editor" + ? EDITOR_EXPORT_EXCLUDED + : LISTING_EXPORT_EXCLUDED, + ), ), parents, version: CATALOG_TRANSFER_VERSION, diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index a6f7ac41d6..827124b803 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -36,6 +36,7 @@ import { type NameIndex, normalizeEntityName, } from "#shared/db/name-registry.ts"; +import { settings } from "#shared/db/settings.ts"; import { type EdgeListing, edgeFieldError, @@ -218,8 +219,9 @@ const validateParentEdges = async ( /** Apply role/site policy the interactive create paths enforce but a raw blob * bypasses: an `editor` may not set a webhook URL (it receives attendee PII) or - * toggle `use_defaults`, and a listing can only assign a built site where the - * builder is configured. */ + * toggle `use_defaults`; a listing can only assign a built site where the + * builder is configured; and logistics can only be required where logistics is + * enabled (the form forces `uses_logistics` off otherwise). */ const applyImportPolicy = ( input: ListingInput, adminLevel: AdminLevel | undefined, @@ -233,6 +235,9 @@ const applyImportPolicy = ( policed.assignBuiltSite = false; policed.initialSiteMonths = 0; } + if (!settings.hasLogistics) { + policed.usesLogistics = false; + } return policed; }; diff --git a/src/features/admin/catalog-transfer/routes.ts b/src/features/admin/catalog-transfer/routes.ts index 183372d35c..ac2c454d18 100644 --- a/src/features/admin/catalog-transfer/routes.ts +++ b/src/features/admin/catalog-transfer/routes.ts @@ -8,7 +8,12 @@ */ import { t } from "#i18n"; -import { CONTENT_MULTIPART, requireContentOr, withAuth } from "#routes/auth.ts"; +import { + type AuthSession, + CONTENT_MULTIPART, + requireContentOr, + withAuth, +} from "#routes/auth.ts"; import { applyFlash } from "#routes/csrf.ts"; import { encodeBody, @@ -44,16 +49,18 @@ const catalogFilename = (kind: string, name: string): string => { }; /** Content-gated export download: load the blob by id (404 when absent) and - * stream it as a named JSON attachment. Shared by both entity kinds. */ + * stream it as a named JSON attachment. `load` receives the session so an + * export can apply role policy (e.g. hide editor-forbidden columns). Shared by + * both entity kinds. */ const downloadExport = ( request: Request, id: number, - load: (id: number) => Promise, + load: (id: number, session: AuthSession) => Promise, kind: string, nameOf: (blob: T) => string, ): Promise => - requireContentOr(request, async () => { - const blob = await load(id); + requireContentOr(request, async (session) => { + const blob = await load(id, session); return blob ? jsonDownload(blob, catalogFilename(kind, nameOf(blob))) : notFoundResponse(); @@ -63,13 +70,25 @@ const downloadExport = ( const handleListingExport: TypedRouteHandler< "GET /admin/listing/:id/export.json" > = (request, { id }) => - downloadExport(request, id, exportListing, "listing", (b) => b.listing.name); + downloadExport( + request, + id, + (listingId, session) => exportListing(listingId, session.adminLevel), + "listing", + (b) => b.listing.name, + ); /** GET /admin/groups/:id/export.json — download a group's export blob. */ const handleGroupExport: TypedRouteHandler< "GET /admin/groups/:id/export.json" > = (request, { id }) => - downloadExport(request, id, exportGroup, "group", (b) => b.group.name); + downloadExport( + request, + id, + (groupId) => exportGroup(groupId), + "group", + (b) => b.group.name, + ); /** GET /admin/catalog/import — the import upload form. */ const handleImportGet: TypedRouteHandler<"GET /admin/catalog/import"> = ( diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 6acfb7a449..b9c855a4c2 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -18,8 +18,25 @@ */ import * as v from "valibot"; +import { DAY_NAMES } from "#shared/dates.ts"; import { ListingTypeSchema, MAX_DURATION_DAYS } from "#shared/types.ts"; +/** True when `value` is storable as a datetime — empty (no value) or a string + * the datetime column normaliser can parse. Mirrors that normaliser's leniency: + * a missing timezone suffix is treated as UTC. An unparseable value would be + * logged and silently stored as empty, so it must be a field error on import. */ +const isStorableDatetime = (value: string): boolean => { + if (value === "") return true; + const withTz = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`; + return !Number.isNaN(new Date(withTz).getTime()); +}; + +/** A datetime column value: empty, or a parseable datetime (see above). */ +const DatetimeSchema = v.pipe( + v.string(), + v.check(isStorableDatetime, "must be a valid datetime"), +); + /** Bump when the format changes incompatibly; a blob at another version is * rejected with an intelligible message rather than mis-imported. */ export const CATALOG_TRANSFER_VERSION = 1; @@ -67,11 +84,13 @@ const optPositiveInt = v.optional(PositiveIntSchema); export const ListingDataSchema = v.object({ active: optBoolean, assignBuiltSite: optBoolean, - bookableDays: v.optional(v.array(v.string())), + // Only real weekday names are bookable; a typo ("Funday") would leave a daily + // listing with dates that never match, so it is a field error on import. + bookableDays: v.optional(v.array(v.picklist(DAY_NAMES))), canPayMore: optBoolean, - closesAt: v.optional(v.nullable(v.string())), + closesAt: v.optional(v.nullable(DatetimeSchema)), customisableDays: optBoolean, - date: optString, + date: v.optional(DatetimeSchema), dayPrices: v.optional(DayPricesSchema), description: optString, durationDays: optPositiveInt, diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index 3dddd33824..e09ec6d5fe 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -15,6 +15,7 @@ import { import { getParentIds, setChildIds } from "#shared/db/listing-parents.ts"; import { getGroupDayPrices } from "#shared/db/listing-prices.ts"; import { getListing } from "#shared/db/listings.ts"; +import { settings } from "#shared/db/settings.ts"; import { createTestGroup, createTestListing, @@ -440,6 +441,120 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect(await importBuiltSiteListing("No Builder")).toBe(false); }); + test("rejects a non-date closesAt", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { closesAt: "not-a-date", maxAttendees: 1, name: "Bad Close" }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("closesAt"); + }); + + test("rejects a non-date event date", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { date: "soon", maxAttendees: 1, name: "Bad Date" }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("date"); + }); + + test("accepts datetimes with and without a timezone suffix", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + // With an explicit offset and without one (treated as UTC). + closesAt: "2026-06-01T12:00:00Z", + date: "2026-06-02T09:00:00", + maxAttendees: 1, + name: "Timed Listing", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const imported = (await getListing(result.id))!; + expect(imported.closes_at).toContain("2026-06-01"); + }); + + test("accepts an explicitly empty closesAt (never closes)", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { closesAt: "", maxAttendees: 1, name: "Open Listing" }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.closes_at).toBeNull(); + }); + + test("rejects an invalid bookable day name", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + bookableDays: ["Funday"], + listingType: "daily", + maxAttendees: 1, + name: "Bad Days", + }, + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("bookableDays"); + }); + + test("accepts valid bookable day names", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + bookableDays: ["Monday", "Wednesday"], + listingType: "daily", + maxAttendees: 1, + name: "Good Days", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.bookable_days).toContain("Monday"); + }); + + test("clears uses-logistics when logistics is disabled", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Logi Off", usesLogistics: true }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.uses_logistics).toBe(false); + }); + + test("keeps uses-logistics when logistics is enabled", async () => { + settings.setForTest({ has_logistics: true }); + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Logi On", usesLogistics: true }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.uses_logistics).toBe(true); + }); + + test("hides the webhook URL from an editor export", async () => { + const listing = await createTestListing({ + name: "Hooked", + webhookUrl: "https://example.com/hook", + }); + // The editor blob must not carry the PII sink URL the edit form hides… + const editorBlob = (await exportListing(listing.id, "editor"))!; + expect(editorBlob.listing.webhookUrl).toBeUndefined(); + // …but staff still round-trip it. + const ownerBlob = (await exportListing(listing.id, "owner"))!; + expect(ownerBlob.listing.webhookUrl).toBe("https://example.com/hook"); + }); + test("batches many memberships into at most two statements", async () => { // The interactive transaction caps at 30 statements, so a large group's // memberships must not be one statement each. membershipStatements collapses From 449223fa97f8cbbd7f66456dd4b2c2120b0d1564 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 22:41:17 +0000 Subject: [PATCH 12/28] Batch parent-edge import; enforce name uniqueness in bulk group duplication Codex #2: a valid export with >25 parents tripped the request N+1 guard (one getParentIds query per parent) and the transaction round-trip cap (one INSERT per parent). Batch the nested-parent check via getChildListingIds and make addParentEdgesTx a single multi-row INSERT. Codex #3: the group-duplicate flow cloned listing names verbatim via raw insertStatement, bypassing the new uniqueness validator and leaving duplicate names that make later name-based imports ambiguous. Validate the new group name and every clone name (against the catalog and within the batch) before writing, rejecting with an operator-facing message. Existing duplicate tests updated to supply a find/replace that keeps clone names unique. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/bulk-actions.ts | 35 ++++++++ src/features/admin/catalog-transfer/import.ts | 15 +++- src/shared/db/listing-parents.ts | 17 ++-- test/bulk-actions/duplicate.test.ts | 81 +++++++++++++++++-- test/lib/catalog-transfer.test.ts | 28 +++++++ test/lib/server-parents-duplicate.test.ts | 64 +++++++++++---- 6 files changed, 206 insertions(+), 34 deletions(-) diff --git a/src/features/admin/bulk-actions.ts b/src/features/admin/bulk-actions.ts index 058af268d7..f8d1b5de33 100644 --- a/src/features/admin/bulk-actions.ts +++ b/src/features/admin/bulk-actions.ts @@ -40,8 +40,13 @@ import { } from "#shared/db/listing-prices.ts"; import { getStoredListingWithCount, + type ListingInput, listingsTable, } from "#shared/db/listings.ts"; +import { + isNameTakenAnywhere, + normalizeEntityName, +} from "#shared/db/name-registry.ts"; import { getFlash } from "#shared/flash-context.ts"; import { buildDuplicateListingInput, @@ -139,6 +144,31 @@ const handleReactivateGroupPost = groupTogglePost({ active: true, }); +/** The first generated name — the new group or one of the clones — that would + * break the cross-entity name invariant (already used by another listing/group, + * or duplicated within this batch), or null when every name is unique. The batch + * insert below bypasses the create-path validators, so the rule the form/API + * enforce is re-checked here; otherwise a blank find/replace would clone names + * verbatim and later make name-based catalog imports ambiguous. */ +const firstDuplicateNameError = async ( + newGroupName: string, + cloneInputs: readonly { input: ListingInput }[], +): Promise => { + const seen = new Set(); + const names = [newGroupName, ...cloneInputs.map(({ input }) => input.name)]; + for (const name of names) { + const key = normalizeEntityName(name); + if (seen.has(key)) { + return `More than one duplicated listing or group would be named "${name}" — set a find/replace so each name is unique.`; + } + seen.add(key); + if (await isNameTakenAnywhere(name)) { + return `A listing or group named "${name}" already exists — choose a different group name, or a find/replace that makes each clone's name unique.`; + } + } + return null; +}; + /** POST /admin/groups/:id/bulk-actions/duplicate */ const handleDuplicateGroupPost = groupFormPost(async (group, form) => { const formUrl = `/admin/groups/${group.id}/bulk-actions/duplicate`; @@ -173,6 +203,11 @@ const handleDuplicateGroupPost = groupFormPost(async (group, form) => { }; }), ); + // Reject before any write if the new group name or a clone name collides + // (with an existing entity or another clone) — upholding the name invariant. + const nameError = await firstDuplicateNameError(newName, cloneInputs); + if (nameError) return errorRedirect(formUrl, nameError); + const memberBySource = new Map( (await getGroupPackagePrices(group.id)).map((row) => [row.listing_id, row]), ); diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index 827124b803..0f9df59fff 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -21,7 +21,10 @@ import { } from "#routes/admin/groups.ts"; import { writeRowInTransaction } from "#shared/db/client.ts"; import { type GroupInput, groupsTable } from "#shared/db/groups.ts"; -import { addParentEdgesTx, getParentIds } from "#shared/db/listing-parents.ts"; +import { + addParentEdgesTx, + getChildListingIds, +} from "#shared/db/listing-parents.ts"; import { syncListingPrices } from "#shared/db/listing-prices.ts"; import { getListingsById, @@ -199,14 +202,20 @@ const validateParentEdges = async ( return `"${input.name}" is a member of the package "${pkg.name}", so it cannot also be an add-on child of another listing.`; } const childEdge = listingInputToEdge(input, 0); - const byId = await getListingsById(); + // Two batched reads (never one query per parent): the listing rows, and the + // subset of parents that are themselves children — a many-parent import must + // not trip the request's N+1 guard. + const [byId, nestedParents] = await Promise.all([ + getListingsById(), + getChildListingIds(parentIds), + ]); for (const parentId of parentIds) { // parentIds were resolved by name from the same cached catalog byId reads, // so every id is present (trust the invariant rather than guard a dead path). const parent = byId.get(parentId)!; // Single-level nesting only: a parent that is itself a child of another // listing can't gain a child (the edge editor rejects the same shape). - if ((await getParentIds(parentId)).length > 0) { + if (nestedParents.has(parentId)) { return t("listings_table.children_err_parent_is_child", { name: parent.name, }); diff --git a/src/shared/db/listing-parents.ts b/src/shared/db/listing-parents.ts index 4606a37ab3..06d29de3e3 100644 --- a/src/shared/db/listing-parents.ts +++ b/src/shared/db/listing-parents.ts @@ -96,17 +96,22 @@ export const setChildIds = ( /** Add `childId` as a child under each of `parentIds` inside an existing write * transaction — the catalog-import writer for a freshly-created listing that is - * a child of already-existing parents. Additive (one INSERT per parent), so it - * never disturbs a parent's other children the way {@link setChildIdsTx}'s - * replace would. */ + * a child of already-existing parents. Additive (never a group-wide delete), so + * it can't disturb a parent's other children the way {@link setChildIdsTx}'s + * replace would. One batched multi-row INSERT regardless of parent count, so a + * many-parent import stays within the interactive-transaction round-trip cap. */ export const addParentEdgesTx = async ( tx: TxScope, childId: number, parentIds: readonly number[], ): Promise => { - for (const parentId of parentIds) { - await tx.execute({ args: [parentId, childId], sql: INSERT_EDGE }); - } + if (parentIds.length === 0) return; + await tx.execute({ + args: parentIds.flatMap((parentId) => [parentId, childId]), + sql: `INSERT INTO listing_parents (parent_listing_id, child_listing_id) VALUES ${parentIds + .map(() => "(?, ?)") + .join(", ")}`, + }); }; /** Replace a parent's child edges inside an existing write transaction, so the diff --git a/test/bulk-actions/duplicate.test.ts b/test/bulk-actions/duplicate.test.ts index 117c9c09a1..d770cb2c87 100644 --- a/test/bulk-actions/duplicate.test.ts +++ b/test/bulk-actions/duplicate.test.ts @@ -24,6 +24,24 @@ import { const getDuplicateForm = getBulkActionForm("duplicate"); +/** POST a duplicate that must be rejected on name uniqueness: assert it + * redirects back to the form and creates no new group. */ +const expectDuplicateRejected = async ( + groupId: number, + body: Record, +): Promise => { + const before = (await getAllGroups()).length; + const { response } = await adminFormPost( + `/admin/groups/${groupId}/bulk-actions/duplicate`, + body, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain( + `/admin/groups/${groupId}/bulk-actions/duplicate`, + ); + expect((await getAllGroups()).length).toBe(before); +}; + describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { describe("GET /admin/groups/:id/bulk-actions/duplicate", () => { test("renders the duplicate form with listing preview data", async () => { @@ -119,7 +137,11 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { const { response } = await adminFormPost( `/admin/groups/${group.id}/bulk-actions/duplicate`, - { new_name: "Priced Copy" }, + { + name_find: "Priced", + name_replace: "Cloned", + new_name: "Priced Copy", + }, ); expect(response.status).toBe(302); @@ -146,6 +168,8 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { }); await adminFormPost(`/admin/groups/${group.id}/bulk-actions/duplicate`, { + name_find: "Inheriting", + name_replace: "Cloned", new_name: "Inherits copy", }); @@ -158,7 +182,7 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { expect((await getStoredListingWithCount(clone.id))?.hidden).toBe(false); }); - test("duplicates with no replacements copies names and dates verbatim", async () => { + test("copies dates verbatim when no date replacement is given", async () => { const group = await createTestGroup({ name: "Verbatim" }); const sourceListing = await createTestListing({ date: "2026-05-01T10:00", @@ -166,13 +190,16 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { name: "Untouched", }); + // A name replacement is still required — names are unique, so a clone may + // not keep the source's name — but an empty date replacement leaves the + // date verbatim. const { response } = await adminFormPost( `/admin/groups/${group.id}/bulk-actions/duplicate`, { date_find: "", date_replace: "", - name_find: "", - name_replace: "", + name_find: "Untouched", + name_replace: "Renamed", new_name: "Verbatim Copy", }, ); @@ -183,10 +210,43 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { ); expect(newGroup).toBeDefined(); const newListings = await getListingsByGroupId(newGroup!.id); - expect(newListings[0]!.name).toBe("Untouched"); + expect(newListings[0]!.name).toBe("Renamed"); expect(newListings[0]!.date).toBe(sourceListing.date); }); + test("rejects a duplicate whose clone name would collide", async () => { + // A blank find/replace clones the source name verbatim, which would + // collide with the still-existing source listing — the name invariant + // rejects it before any write. + const group = await createTestGroup({ name: "Clashy" }); + await createTestListing({ groupId: group.id, name: "Only Member" }); + await expectDuplicateRejected(group.id, { new_name: "Clashy Copy" }); + }); + + test("rejects a new group name already used by another entity", async () => { + const group = await createTestGroup({ name: "Dup Src" }); + await createTestListing({ groupId: group.id, name: "A Member" }); + await createTestListing({ name: "Taken Name" }); + await expectDuplicateRejected(group.id, { + name_find: "A Member", + name_replace: "A Clone", + new_name: "Taken Name", + }); + }); + + test("rejects when a clone name would equal the new group name", async () => { + // The new group name and a clone name collide within the batch (both + // brand-new), which no create-path validator would see — caught up front. + const group = await createTestGroup({ name: "Collapse" }); + await createTestListing({ groupId: group.id, name: "Sole Member" }); + // The clone is renamed to exactly the new group name. + await expectDuplicateRejected(group.id, { + name_find: "Sole Member", + name_replace: "Shared Name", + new_name: "Shared Name", + }); + }); + test("duplicates a large group without tripping the transaction round-trip guard", async () => { // 16 listings would be 1 + 16 + 16 = 33 statements in an interactive // transaction (guard fires at 30); the single-batch clone must stay clear @@ -201,8 +261,9 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { { date_find: "", date_replace: "", - name_find: "", - name_replace: "", + // "Listing N" → "Clone N" keeps every clone name unique. + name_find: "Listing", + name_replace: "Clone", new_name: "Big Copy", }, ); @@ -274,7 +335,11 @@ describeWithEnv("Admin bulk actions — duplicate", { db: true }, () => { const { response } = await adminFormPost( `/admin/groups/${group.id}/bulk-actions/duplicate`, - { new_name: "Pkg Copy" }, + { + name_find: "Member", + name_replace: "Cloned Member", + new_name: "Pkg Copy", + }, ); expect(response.status).toBe(302); diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index e09ec6d5fe..b69f19370d 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -542,6 +542,34 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect((await getListing(result.id))!.uses_logistics).toBe(true); }); + test("imports a listing with many parents in one batched insert", async () => { + // More parents than the per-request N+1 guard (25) and the transaction + // round-trip cap (~30) would allow one query/insert each — proves the + // batched nested-parent check and the set-wise edge insert. + const { computeSlugIndex, listingsTable } = await import( + "#shared/db/listings.ts" + ); + const parentNames = Array.from({ length: 30 }, (_, i) => `Parent ${i}`); + for (const name of parentNames) { + const slug = name.toLowerCase().replace(/\s+/g, "-"); + await listingsTable.insert({ + maxAttendees: 1, + maxPrice: 0, + name, + slug, + slugIndex: await computeSlugIndex(slug), + }); + } + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Many Kids" }, + parents: parentNames, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getParentIds(result.id)).length).toBe(30); + }); + test("hides the webhook URL from an editor export", async () => { const listing = await createTestListing({ name: "Hooked", diff --git a/test/lib/server-parents-duplicate.test.ts b/test/lib/server-parents-duplicate.test.ts index d7ffd33588..6275bfe70a 100644 --- a/test/lib/server-parents-duplicate.test.ts +++ b/test/lib/server-parents-duplicate.test.ts @@ -89,16 +89,20 @@ const optInAddOnScopedTo = async ( for (const id of listingIds) await linkModifierListing(modifier.id, id); }; -/** Duplicate a whole group and return the cloned group's listings. */ +/** Duplicate a whole group and return the cloned group's listings. A name + * find/replace is required so the clones don't reuse the source names (which + * the cross-entity uniqueness rule forbids). */ const duplicateGroup = async ( groupId: number, newName: string, + nameFind: string, + nameReplace: string, ): Promise>> => { await adminFormPost(`/admin/groups/${groupId}/bulk-actions/duplicate`, { date_find: "", date_replace: "", - name_find: "", - name_replace: "", + name_find: nameFind, + name_replace: nameReplace, new_name: newName, }); const newGroup = (await getAllGroups()).find((g) => g.name === newName); @@ -108,18 +112,20 @@ const duplicateGroup = async ( /** Duplicate a whole group, returning the raw redirect Response (without * following it) so callers can assert a warning flash. The body is cancelled * because the warning-tests never render the redirect target — they only - * inspect the flash cookie. */ + * inspect the flash cookie. A name find/replace keeps clone names unique. */ const duplicateGroupResponse = async ( groupId: number, newName: string, + nameFind: string, + nameReplace: string, ): Promise => { const { response } = await adminFormPost( `/admin/groups/${groupId}/bulk-actions/duplicate`, { date_find: "", date_replace: "", - name_find: "", - name_replace: "", + name_find: nameFind, + name_replace: nameReplace, new_name: newName, }, ); @@ -229,10 +235,15 @@ describeWithEnv( parent: { name: "Group parent" }, }); - const copies = await duplicateGroup(group!.id, "Bundle copy"); + const copies = await duplicateGroup( + group!.id, + "Bundle copy", + "Group", + "Cloned", + ); - const parentCopy = copies.find((l) => l.name === "Group parent")!; - const childCopy = copies.find((l) => l.name === "Group child")!; + const parentCopy = copies.find((l) => l.name === "Cloned parent")!; + const childCopy = copies.find((l) => l.name === "Cloned child")!; // The cloned parent requires the cloned child, not the original. expect(await getChildIds(parentCopy.id)).toEqual([childCopy.id]); expect(childCopy.id).not.toBe(child.id); @@ -247,9 +258,14 @@ describeWithEnv( const outsideChild = await createTestListing({ name: "Outside child" }); await setChildren(parent.id, [outsideChild.id]); - const copies = await duplicateGroup(group.id, "External copy"); + const copies = await duplicateGroup( + group.id, + "External copy", + "Inside", + "Cloned", + ); - const parentCopy = copies.find((l) => l.name === "Inside parent")!; + const parentCopy = copies.find((l) => l.name === "Cloned parent")!; // The external child is referenced by its original id (not cloned). expect(await getChildIds(parentCopy.id)).toEqual([outsideChild.id]); }); @@ -267,9 +283,14 @@ describeWithEnv( }); await setChildren(outsideParent.id, [child.id]); - const copies = await duplicateGroup(group.id, "Child only copy"); + const copies = await duplicateGroup( + group.id, + "Child only copy", + "Inside", + "Cloned", + ); - const childCopy = copies.find((l) => l.name === "Inside child")!; + const childCopy = copies.find((l) => l.name === "Cloned child")!; // The outside parent now gates BOTH the original child and its clone. expect((await getChildIds(outsideParent.id)).sort()).toEqual( [child.id, childCopy.id].sort(), @@ -312,13 +333,15 @@ describeWithEnv( const response = await duplicateGroupResponse( group.id, "Stranded bundle copy", + "Bundle parent", + "Cloned parent", ); const newGroup = (await getAllGroups()).find( (g) => g.name === "Stranded bundle copy", )!; const copies = await getListingsByGroupId(newGroup.id); - const parentCopy = copies.find((l) => l.name === "Bundle parent")!; + const parentCopy = copies.find((l) => l.name === "Cloned parent")!; // The cloned parent has NO gate (the invalid edge was not written) rather // than a silently-gateless standalone reported as success. expect(await getChildIds(parentCopy.id)).toEqual([]); @@ -356,13 +379,15 @@ describeWithEnv( const response = await duplicateGroupResponse( group.id, "Incoming bundle copy", + "Bundled", + "Cloned", ); const newGroup = (await getAllGroups()).find( (g) => g.name === "Incoming bundle copy", )!; const childCopy = (await getListingsByGroupId(newGroup.id)).find( - (l) => l.name === "Bundled add-on", + (l) => l.name === "Cloned add-on", )!; // The incoming edge `outsideParent -> childCopy` was NOT written (the full // set re-validation failed), so the external parent keeps only its @@ -395,8 +420,13 @@ describeWithEnv( await settings.update.showPublicSite(true); const { group } = await makeExternalBundle("Outside-parent bundle"); - const copies = await duplicateGroup(group.id, "Outside-parent bundle 2"); - const childCopy = copies.find((l) => l.name === "Bundled add-on")!; + const copies = await duplicateGroup( + group.id, + "Outside-parent bundle 2", + "Bundled", + "Cloned", + ); + const childCopy = copies.find((l) => l.name === "Cloned add-on")!; const { handleRequest } = await import("#routes"); const { mockRequest } = await import("#test-utils"); From 434c693b032d206ef34f6f362dcb23e5bdafe845 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 23:42:45 +0000 Subject: [PATCH 13/28] Unify listing field-value validation across form, API, and import Codex kept finding fields where a raw import/API build accepts a value the listing form would reject (over-cap duration silently clamped, invalid contact fields dropped, bad weekday names, impossible datetimes rolled over). Root cause: the form's per-field validators live only in the form's field defs, so the JSON API and the catalog import bypass them. - Extract those validators (contact fields, weekday names, duration cap, plus a combined validateListingFieldValues) into #shared/listing-field-validators; the form re-exports them so its behaviour is unchanged, and the JSON API now runs them too (validateListingApiInput). - The catalog transfer schema validates the same field values at its wire boundary, including a self-contained strict datetime check that rejects impossible calendar dates (e.g. 2026-02-30) rather than letting the storage layer roll them over. Kept self-contained (no Temporal/timezone import) so this early-loaded schema module stays out of the settings-loading graph. - Field-value import tests live in their own file to keep the main catalog-transfer suite's per-request read count under the N+1 guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/api.ts | 13 +- src/features/admin/catalog-transfer/schema.ts | 86 +++++++++-- src/shared/listing-field-validators.ts | 127 ++++++++++++++++ src/ui/templates/fields.ts | 85 +++-------- test/lib/catalog-transfer-fields.test.ts | 136 ++++++++++++++++++ test/lib/catalog-transfer.test.ts | 80 ----------- 6 files changed, 366 insertions(+), 161 deletions(-) create mode 100644 src/shared/listing-field-validators.ts create mode 100644 test/lib/catalog-transfer-fields.test.ts diff --git a/src/features/admin/api.ts b/src/features/admin/api.ts index c5ed0fac91..d5dccf123d 100644 --- a/src/features/admin/api.ts +++ b/src/features/admin/api.ts @@ -30,6 +30,7 @@ import { type ListingInput, listingsTable, } from "#shared/db/listings.ts"; +import { validateListingFieldValues } from "#shared/listing-field-validators.ts"; import { deleteOrphanedAddOnError, generateUniqueListingSlug, @@ -518,6 +519,16 @@ const hydrateListingGroupIds = async ( ); }; +/** The JSON API builds a `ListingInput` directly, so — like the catalog import + * — it must run the form's per-field value rules (which `validateListingInput` + * itself does not) in addition to the cross-entity checks. */ +const validateListingApiInput = async ( + input: ListingInput, + existingId?: number, +): Promise => + validateListingFieldValues(input) ?? + (await validateListingInput(input, existingId)); + const listingApiRoutes = defineCrudApi< Listing, ListingInput, @@ -566,7 +577,7 @@ const listingApiRoutes = defineCrudApi< table: listingsTable, toCreateInput: bodyToCreateInput, toUpdateInput: bodyToUpdateInput, - validate: validateListingInput, + validate: validateListingApiInput, }); export const adminApiRoutes = { diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index b9c855a4c2..5520d07866 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -18,20 +18,43 @@ */ import * as v from "valibot"; -import { DAY_NAMES } from "#shared/dates.ts"; -import { ListingTypeSchema, MAX_DURATION_DAYS } from "#shared/types.ts"; +import { + isContactField, + ListingTypeSchema, + MAX_DURATION_DAYS, +} from "#shared/types.ts"; -/** True when `value` is storable as a datetime — empty (no value) or a string - * the datetime column normaliser can parse. Mirrors that normaliser's leniency: - * a missing timezone suffix is treated as UTC. An unparseable value would be - * logged and silently stored as empty, so it must be a field error on import. */ +/** + * True when `value` is storable in a datetime column: empty (no value), or a + * real calendar datetime — a naive `YYYY-MM-DDTHH:MM[:SS]` or an offset instant + * (the exported shape). Impossible dates like `2026-02-30` are rejected (a bare + * `Date` would silently roll them into March). Deliberately self-contained (no + * Temporal/timezone import) so this early-loaded schema module stays free of the + * settings-loading graph; it matches the strictness of the form's validator. + */ const isStorableDatetime = (value: string): boolean => { if (value === "") return true; - const withTz = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`; - return !Number.isNaN(new Date(withTz).getTime()); + const m = value.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/, + ); + if (!m) return false; + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const h = Number(m[4]); + const mi = Number(m[5]); + const s = m[6] === undefined ? 0 : Number(m[6]); + if (mo < 1 || mo > 12 || d < 1 || h > 23 || mi > 59 || s > 59) return false; + // Round-trip through UTC: a rolled-over impossible date won't match its parts. + const dt = new Date(Date.UTC(y, mo - 1, d)); + return ( + dt.getUTCFullYear() === y && + dt.getUTCMonth() === mo - 1 && + dt.getUTCDate() === d + ); }; -/** A datetime column value: empty, or a parseable datetime (see above). */ +/** A datetime column value: empty, or a real calendar datetime (see above). */ const DatetimeSchema = v.pipe( v.string(), v.check(isStorableDatetime, "must be a valid datetime"), @@ -48,6 +71,43 @@ const intAtLeast = (min: number) => const NonNegativeIntSchema = intAtLeast(0); /** A whole positive integer (durations, quantities). */ const PositiveIntSchema = intAtLeast(1); +/** A booking duration in whole days: 1..MAX_DURATION_DAYS, matching the listing + * form's cap so an over-limit blob is a field error, not silently clamped. */ +const DurationDaysSchema = v.pipe( + v.number(), + v.integer(), + v.minValue(1), + v.maxValue(MAX_DURATION_DAYS, `must be at most ${MAX_DURATION_DAYS} days`), +); +/** A single valid contact-field name (email/phone/address/…). */ +const ContactFieldSchema = v.custom( + (value) => typeof value === "string" && isContactField(value), + "must be a known contact field", +); +/** The `fields` column: a comma-separated list of valid contact-field names, so + * a typo ("fax") is a field error rather than a silently-dropped entry. */ +const FieldsSchema = v.pipe( + v.string(), + v.transform((value) => + value + .split(",") + .map((part) => part.trim()) + .filter((part) => part), + ), + v.array(ContactFieldSchema), + v.transform((parts) => parts.join(",")), +); +/** A bookable weekday name — validated so a typo ("Funday") is a field error + * rather than a day that never matches an availability check. */ +const BookableDaySchema = v.picklist([ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]); /** A minor-unit price — a non-negative integer. */ const PriceSchema = intAtLeast(0); /** A required, trimmed, non-empty name reference. */ @@ -84,17 +144,15 @@ const optPositiveInt = v.optional(PositiveIntSchema); export const ListingDataSchema = v.object({ active: optBoolean, assignBuiltSite: optBoolean, - // Only real weekday names are bookable; a typo ("Funday") would leave a daily - // listing with dates that never match, so it is a field error on import. - bookableDays: v.optional(v.array(v.picklist(DAY_NAMES))), + bookableDays: v.optional(v.array(BookableDaySchema)), canPayMore: optBoolean, closesAt: v.optional(v.nullable(DatetimeSchema)), customisableDays: optBoolean, date: v.optional(DatetimeSchema), dayPrices: v.optional(DayPricesSchema), description: optString, - durationDays: optPositiveInt, - fields: optString, + durationDays: v.optional(DurationDaysSchema), + fields: v.optional(FieldsSchema), hidden: optBoolean, initialSiteMonths: optNonNegInt, listingType: v.optional(ListingTypeSchema), diff --git a/src/shared/listing-field-validators.ts b/src/shared/listing-field-validators.ts new file mode 100644 index 0000000000..61ec1440ec --- /dev/null +++ b/src/shared/listing-field-validators.ts @@ -0,0 +1,127 @@ +/** + * Per-field value validators for a listing, over plain string inputs. + * + * These are the single definition of "is this listing field value valid" — + * shared by the HTML form (via the field defs in `#templates/fields.ts`) and by + * `validateListingInput`, so the form, the admin JSON API, and the catalog + * import all enforce the same rules. They live in `#shared` (depending only on + * lightweight shared primitives) so the hot `listings-actions` module can reuse + * them without pulling in the UI field framework. + */ + +import { t } from "#i18n"; +import { + isContactField, + isListingType, + MAX_DURATION_DAYS, +} from "#shared/types.ts"; + +/** Split a comma-separated string into trimmed, non-empty tokens. */ +export const splitCsv = (value: string): string[] => + value + .split(",") + .map((d) => d.trim()) + .filter((d) => d); + +/** Valid day names for bookable_days (Monday-first for display). Kept as a + * literal (rather than derived from `#shared/dates`) so this validator module + * stays free of the settings-loading import graph and can be reused from the + * hot `listings-actions` path without perturbing per-request settings caching. */ +export const VALID_DAY_NAMES = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +/** Check if a string is a valid day name. */ +const isValidDayName = (s: string): boolean => + (VALID_DAY_NAMES as readonly string[]).includes(s); + +/** Validate listing fields setting (comma-separated contact field names). */ +export const validateListingFields = (value: string): string | null => { + for (const part of splitCsv(value)) { + if (!isContactField(part)) { + return t("fields.validation.invalid_contact_field", { part }); + } + } + return null; +}; + +/** Validate listing type setting. */ +export const validateListingType = (value: string): string | null => + isListingType(value) ? null : t("fields.validation.listing_type"); + +/** Validate bookable days (comma-separated day names). */ +export const validateBookableDays = (value: string): string | null => { + const days = splitCsv(value); + if (days.length === 0) return t("fields.validation.days_required"); + for (const day of days) { + if (!isValidDayName(day)) { + return t("fields.validation.invalid_day", { + day, + valid: VALID_DAY_NAMES.join(", "), + }); + } + } + return null; +}; + +/** Validate a listing's duration (whole days, 1..MAX_DURATION_DAYS). Shared by + * the form field and the create-input validator, so every create path enforces + * the same cap rather than silently clamping in the storage layer. Callers pass + * a non-empty value (the form's validateSingleField guarantees it; the input + * validator guards undefined itself). */ +export const validateDurationDays = (value: string): string | null => { + const parsed = Number(value); + if (!Number.isInteger(parsed)) { + return t("fields.validation.duration_whole"); + } + if (parsed < 1) return t("fields.validation.duration_min"); + if (parsed > MAX_DURATION_DAYS) { + return t("fields.validation.duration_max", { max: MAX_DURATION_DAYS }); + } + return null; +}; + +/** The listing fields whose values these validators police (a structural subset + * of `ListingInput`, so callers pass their input directly). */ +export type ListingFieldValues = { + listingType?: string | undefined; + bookableDays?: string[] | undefined; + fields?: string | undefined; + durationDays?: number | undefined; +}; + +/** + * Enforce the per-field value rules the listing form declares (via + * `getListingFields`), against an already-typed listing input. The HTML form + * runs these through `validateForm`; the admin JSON API and the catalog import + * build the input directly and would otherwise bypass them, so running the SAME + * validators there makes every create path enforce one definition of a valid + * field value (a bad weekday, contact field, listing type, or over-cap duration + * is rejected, not silently normalised). Each validator runs only on a present + * value, mirroring the form's "validate non-empty only" rule so an omitted + * optional field is never flagged. + * + * Datetime fields are deliberately excluded: the form validates the *naive* + * form value, whereas a typed input's datetimes are already UTC-normalised, so + * the shared validator's representation wouldn't match. The catalog import + * validates those at its own (pre-normalisation) schema boundary instead. + */ +export const validateListingFieldValues = ( + input: ListingFieldValues, +): string | null => { + const bookableDays = (input.bookableDays ?? []).join(","); + return ( + (input.listingType ? validateListingType(input.listingType) : null) ?? + (bookableDays ? validateBookableDays(bookableDays) : null) ?? + (input.fields ? validateListingFields(input.fields) : null) ?? + (input.durationDays === undefined + ? null + : validateDurationDays(String(input.durationDays))) + ); +}; diff --git a/src/ui/templates/fields.ts b/src/ui/templates/fields.ts index 532968a209..e8fb60a9ab 100644 --- a/src/ui/templates/fields.ts +++ b/src/ui/templates/fields.ts @@ -5,7 +5,6 @@ import * as v from "valibot"; import { t } from "#i18n"; import { formatCurrency, getDecimalPlaces } from "#shared/currency.ts"; -import { DAY_NAMES } from "#shared/dates.ts"; import { isUpdateTier } from "#shared/db/built-sites.ts"; import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; import type { FormParams } from "#shared/form-data.ts"; @@ -16,6 +15,14 @@ import { MAX_IMAGE_SIZE, MAX_TEXTAREA_LENGTH, } from "#shared/limits.ts"; +import { + splitCsv, + VALID_DAY_NAMES, + validateBookableDays, + validateDurationDays, + validateListingFields, + validateListingType, +} from "#shared/listing-field-validators.ts"; import { mergeListingFields, parseListingFields, @@ -31,8 +38,6 @@ import { type AdminLevel, type ContactField, type ContactInfo, - isContactField, - isListingType, type ListingFields, type ListingType, MAX_DURATION_DAYS, @@ -246,61 +251,21 @@ const getUsernameFieldBase = (): Field => ({ type: "text", }); -/** Validate listing fields setting (comma-separated contact field names) */ -const validateListingFields = (value: string): string | null => { - const parts = value - .split(",") - .map((v) => v.trim()) - .filter((v) => v); - for (const part of parts) { - if (!isContactField(part)) { - return t("fields.validation.invalid_contact_field", { part }); - } - } - return null; -}; - -/** Validate listing type setting */ -const validateListingType = (value: string): string | null => { - if (!isListingType(value)) { - return t("fields.validation.listing_type"); - } - return null; +/** Re-exported so existing importers keep resolving these listing-field + * validators through the field module. */ +export { + splitCsv, + VALID_DAY_NAMES, + validateBookableDays, + validateDurationDays, + validateListingFields, + validateListingType, }; /** Validate a built site's update channel (alpha/beta/release) */ const validateUpdateTier = (value: string): string | null => isUpdateTier(value) ? null : t("fields.validation.update_tier"); -/** Valid day names for bookable_days (Monday-first for display) */ -export const VALID_DAY_NAMES = [...DAY_NAMES.slice(1), DAY_NAMES[0]!]; - -/** Check if a string is a valid day name */ -const isValidDayName = (s: string): boolean => - (VALID_DAY_NAMES as readonly string[]).includes(s); - -/** Split a comma-separated string into trimmed, non-empty tokens */ -export const splitCsv = (value: string): string[] => - value - .split(",") - .map((d) => d.trim()) - .filter((d) => d); - -/** Validate bookable days (comma-separated day names) */ -export const validateBookableDays = (value: string): string | null => { - const days = splitCsv(value); - if (days.length === 0) return t("fields.validation.days_required"); - for (const day of days) { - if (!isValidDayName(day)) { - return t("fields.validation.invalid_day", { - day, - valid: VALID_DAY_NAMES.join(", "), - }); - } - } - return null; -}; - /** Shared formatting hint linking to the admin guide */ // Links to the standalone formatting-help page (not the staff-only full guide), // so the hint works for every content role that edits markdown — including @@ -328,7 +293,7 @@ const buildDescriptionField = (hint: string, hintHtml?: string): Field => ({ }); /** Validate a datetime value is parseable */ -const validateDatetime = (value: string): string | null => +export const validateDatetime = (value: string): string | null => isValidDatetime(value) ? null : t("fields.validation.datetime"); /** Build a "hidden" visibility checkbox field for a listing or group. */ @@ -446,19 +411,7 @@ export const getListingFields = (): Field[] => [ min: 1, name: "duration_days", type: "number", - validate: (value: string): string | null => { - // validateSingleField only calls this when the value is non-empty, so - // the empty-string case never reaches here. - const parsed = Number(value); - if (!Number.isInteger(parsed)) { - return t("fields.validation.duration_whole"); - } - if (parsed < 1) return t("fields.validation.duration_min"); - if (parsed > MAX_DURATION_DAYS) { - return t("fields.validation.duration_max", { max: MAX_DURATION_DAYS }); - } - return null; - }, + validate: validateDurationDays, }, { hint: t("fields.listing.customisable_days_hint"), diff --git a/test/lib/catalog-transfer-fields.test.ts b/test/lib/catalog-transfer-fields.test.ts new file mode 100644 index 0000000000..e4acce4556 --- /dev/null +++ b/test/lib/catalog-transfer-fields.test.ts @@ -0,0 +1,136 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { getListing } from "#shared/db/listings.ts"; +import { describeWithEnv } from "#test-utils"; + +/** Import a one-listing blob and assert it is rejected with a message naming + * the offending field. */ +const expectListingImportError = async ( + listing: Record, + contains: string, +): Promise => { + const result = await importCatalog({ kind: "listing", listing, version: 1 }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain(contains); +}; + +// The listing form validates field VALUES (datetime, duration cap, contact +// fields, weekday names) via its field definitions; the catalog import builds a +// listing directly, so the transfer schema re-checks the same rules at its wire +// boundary. Kept in their own file so these value-validation imports don't push +// the main catalog-transfer suite's cumulative per-request read count over the +// N+1 guard. +describeWithEnv("catalog-transfer field validation", { db: true }, () => { + test("rejects a non-date closesAt", async () => { + await expectListingImportError( + { closesAt: "not-a-date", maxAttendees: 1, name: "Bad Close" }, + "closesAt", + ); + }); + + test("rejects a non-date event date", async () => { + await expectListingImportError( + { date: "soon", maxAttendees: 1, name: "Bad Date" }, + "date", + ); + }); + + test("accepts datetimes with and without a timezone suffix", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + // With an explicit offset and without one (treated as UTC). + closesAt: "2026-06-01T12:00:00Z", + date: "2026-06-02T09:00:00", + maxAttendees: 1, + name: "Timed Listing", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const imported = (await getListing(result.id))!; + expect(imported.closes_at).toContain("2026-06-01"); + }); + + test("rejects an impossible calendar date", async () => { + // A real-looking but non-existent date (Feb 30) must be a field error, not + // silently rolled over into March by the storage layer. + await expectListingImportError( + { closesAt: "2026-02-30T00:00:00Z", maxAttendees: 1, name: "Imp" }, + "closesAt", + ); + }); + + test("accepts a naive datetime without seconds", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { closesAt: "2026-06-01T12:00", maxAttendees: 1, name: "NoSec" }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.closes_at).toContain("2026-06-01"); + }); + + test("rejects an out-of-range time", async () => { + // Hour 25 is not a valid time even though the calendar date exists. + await expectListingImportError( + { closesAt: "2026-06-01T25:00:00Z", maxAttendees: 1, name: "BadTime" }, + "closesAt", + ); + }); + + test("accepts an explicitly empty closesAt (never closes)", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { closesAt: "", maxAttendees: 1, name: "Open Listing" }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.closes_at).toBeNull(); + }); + + test("rejects an over-cap duration", async () => { + // durationDays above the 90-day max is a field error, not silently clamped + // by the storage normaliser. + await expectListingImportError( + { durationDays: 365, maxAttendees: 1, name: "Too Long" }, + "90", + ); + }); + + test("rejects an unknown contact field", async () => { + await expectListingImportError( + { fields: "email,fax", maxAttendees: 1, name: "Bad Fields" }, + "fields", + ); + }); + + test("rejects an invalid bookable day name", async () => { + await expectListingImportError( + { + bookableDays: ["Funday"], + listingType: "daily", + maxAttendees: 1, + name: "Bad Days", + }, + "bookableDays", + ); + }); + + test("accepts valid bookable day names", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + bookableDays: ["Monday", "Wednesday"], + listingType: "daily", + maxAttendees: 1, + name: "Good Days", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getListing(result.id))!.bookable_days).toContain("Monday"); + }); +}); diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index b69f19370d..b28df54b84 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -441,86 +441,6 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect(await importBuiltSiteListing("No Builder")).toBe(false); }); - test("rejects a non-date closesAt", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { closesAt: "not-a-date", maxAttendees: 1, name: "Bad Close" }, - version: 1, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("unreachable"); - expect(result.error).toContain("closesAt"); - }); - - test("rejects a non-date event date", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { date: "soon", maxAttendees: 1, name: "Bad Date" }, - version: 1, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("unreachable"); - expect(result.error).toContain("date"); - }); - - test("accepts datetimes with and without a timezone suffix", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { - // With an explicit offset and without one (treated as UTC). - closesAt: "2026-06-01T12:00:00Z", - date: "2026-06-02T09:00:00", - maxAttendees: 1, - name: "Timed Listing", - }, - version: 1, - }); - if (!result.ok) throw new Error(result.error); - const imported = (await getListing(result.id))!; - expect(imported.closes_at).toContain("2026-06-01"); - }); - - test("accepts an explicitly empty closesAt (never closes)", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { closesAt: "", maxAttendees: 1, name: "Open Listing" }, - version: 1, - }); - if (!result.ok) throw new Error(result.error); - expect((await getListing(result.id))!.closes_at).toBeNull(); - }); - - test("rejects an invalid bookable day name", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { - bookableDays: ["Funday"], - listingType: "daily", - maxAttendees: 1, - name: "Bad Days", - }, - version: 1, - }); - expect(result.ok).toBe(false); - if (result.ok) throw new Error("unreachable"); - expect(result.error).toContain("bookableDays"); - }); - - test("accepts valid bookable day names", async () => { - const result = await importCatalog({ - kind: "listing", - listing: { - bookableDays: ["Monday", "Wednesday"], - listingType: "daily", - maxAttendees: 1, - name: "Good Days", - }, - version: 1, - }); - if (!result.ok) throw new Error(result.error); - expect((await getListing(result.id))!.bookable_days).toContain("Monday"); - }); - test("clears uses-logistics when logistics is disabled", async () => { const result = await importCatalog({ kind: "listing", From e349b9be4453c266e4861a1c918a86e5970e4b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:01:50 +0000 Subject: [PATCH 14/28] Scope import field validation to the schema; fix datetime regex anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit ran the form's field validators through the JSON API too, but the API deliberately CLAMPS out-of-range values (no form layer — see the duration-days e2e), so rejecting there broke intended behaviour and a code-quality gate. Revert the API and the fields.ts extraction; keep the actual fix — the catalog transfer schema validates datetime, duration cap, contact fields, and weekday names at its wire boundary, which is where the import findings apply. Also anchor the datetime regex so a valid prefix with trailing junk ("2030-01-01T00:00not-a-zone") is a field error rather than being silently emptied by the storage normaliser. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/api.ts | 13 +- src/features/admin/catalog-transfer/schema.ts | 5 +- src/shared/listing-field-validators.ts | 127 ------------------ src/ui/templates/fields.ts | 85 +++++++++--- test/lib/catalog-transfer-fields.test.ts | 9 ++ 5 files changed, 80 insertions(+), 159 deletions(-) delete mode 100644 src/shared/listing-field-validators.ts diff --git a/src/features/admin/api.ts b/src/features/admin/api.ts index d5dccf123d..c5ed0fac91 100644 --- a/src/features/admin/api.ts +++ b/src/features/admin/api.ts @@ -30,7 +30,6 @@ import { type ListingInput, listingsTable, } from "#shared/db/listings.ts"; -import { validateListingFieldValues } from "#shared/listing-field-validators.ts"; import { deleteOrphanedAddOnError, generateUniqueListingSlug, @@ -519,16 +518,6 @@ const hydrateListingGroupIds = async ( ); }; -/** The JSON API builds a `ListingInput` directly, so — like the catalog import - * — it must run the form's per-field value rules (which `validateListingInput` - * itself does not) in addition to the cross-entity checks. */ -const validateListingApiInput = async ( - input: ListingInput, - existingId?: number, -): Promise => - validateListingFieldValues(input) ?? - (await validateListingInput(input, existingId)); - const listingApiRoutes = defineCrudApi< Listing, ListingInput, @@ -577,7 +566,7 @@ const listingApiRoutes = defineCrudApi< table: listingsTable, toCreateInput: bodyToCreateInput, toUpdateInput: bodyToUpdateInput, - validate: validateListingApiInput, + validate: validateListingInput, }); export const adminApiRoutes = { diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 5520d07866..35cd2ed2cb 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -34,8 +34,11 @@ import { */ const isStorableDatetime = (value: string): boolean => { if (value === "") return true; + // Anchored end ($) so trailing junk ("…T00:00not-a-zone") is rejected rather + // than silently emptied by the storage normaliser: optional seconds, optional + // fractional seconds, and an optional Z / ±HH:MM offset are the only tails. const m = value.match( - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/, + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/, ); if (!m) return false; const y = Number(m[1]); diff --git a/src/shared/listing-field-validators.ts b/src/shared/listing-field-validators.ts deleted file mode 100644 index 61ec1440ec..0000000000 --- a/src/shared/listing-field-validators.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Per-field value validators for a listing, over plain string inputs. - * - * These are the single definition of "is this listing field value valid" — - * shared by the HTML form (via the field defs in `#templates/fields.ts`) and by - * `validateListingInput`, so the form, the admin JSON API, and the catalog - * import all enforce the same rules. They live in `#shared` (depending only on - * lightweight shared primitives) so the hot `listings-actions` module can reuse - * them without pulling in the UI field framework. - */ - -import { t } from "#i18n"; -import { - isContactField, - isListingType, - MAX_DURATION_DAYS, -} from "#shared/types.ts"; - -/** Split a comma-separated string into trimmed, non-empty tokens. */ -export const splitCsv = (value: string): string[] => - value - .split(",") - .map((d) => d.trim()) - .filter((d) => d); - -/** Valid day names for bookable_days (Monday-first for display). Kept as a - * literal (rather than derived from `#shared/dates`) so this validator module - * stays free of the settings-loading import graph and can be reused from the - * hot `listings-actions` path without perturbing per-request settings caching. */ -export const VALID_DAY_NAMES = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", -]; - -/** Check if a string is a valid day name. */ -const isValidDayName = (s: string): boolean => - (VALID_DAY_NAMES as readonly string[]).includes(s); - -/** Validate listing fields setting (comma-separated contact field names). */ -export const validateListingFields = (value: string): string | null => { - for (const part of splitCsv(value)) { - if (!isContactField(part)) { - return t("fields.validation.invalid_contact_field", { part }); - } - } - return null; -}; - -/** Validate listing type setting. */ -export const validateListingType = (value: string): string | null => - isListingType(value) ? null : t("fields.validation.listing_type"); - -/** Validate bookable days (comma-separated day names). */ -export const validateBookableDays = (value: string): string | null => { - const days = splitCsv(value); - if (days.length === 0) return t("fields.validation.days_required"); - for (const day of days) { - if (!isValidDayName(day)) { - return t("fields.validation.invalid_day", { - day, - valid: VALID_DAY_NAMES.join(", "), - }); - } - } - return null; -}; - -/** Validate a listing's duration (whole days, 1..MAX_DURATION_DAYS). Shared by - * the form field and the create-input validator, so every create path enforces - * the same cap rather than silently clamping in the storage layer. Callers pass - * a non-empty value (the form's validateSingleField guarantees it; the input - * validator guards undefined itself). */ -export const validateDurationDays = (value: string): string | null => { - const parsed = Number(value); - if (!Number.isInteger(parsed)) { - return t("fields.validation.duration_whole"); - } - if (parsed < 1) return t("fields.validation.duration_min"); - if (parsed > MAX_DURATION_DAYS) { - return t("fields.validation.duration_max", { max: MAX_DURATION_DAYS }); - } - return null; -}; - -/** The listing fields whose values these validators police (a structural subset - * of `ListingInput`, so callers pass their input directly). */ -export type ListingFieldValues = { - listingType?: string | undefined; - bookableDays?: string[] | undefined; - fields?: string | undefined; - durationDays?: number | undefined; -}; - -/** - * Enforce the per-field value rules the listing form declares (via - * `getListingFields`), against an already-typed listing input. The HTML form - * runs these through `validateForm`; the admin JSON API and the catalog import - * build the input directly and would otherwise bypass them, so running the SAME - * validators there makes every create path enforce one definition of a valid - * field value (a bad weekday, contact field, listing type, or over-cap duration - * is rejected, not silently normalised). Each validator runs only on a present - * value, mirroring the form's "validate non-empty only" rule so an omitted - * optional field is never flagged. - * - * Datetime fields are deliberately excluded: the form validates the *naive* - * form value, whereas a typed input's datetimes are already UTC-normalised, so - * the shared validator's representation wouldn't match. The catalog import - * validates those at its own (pre-normalisation) schema boundary instead. - */ -export const validateListingFieldValues = ( - input: ListingFieldValues, -): string | null => { - const bookableDays = (input.bookableDays ?? []).join(","); - return ( - (input.listingType ? validateListingType(input.listingType) : null) ?? - (bookableDays ? validateBookableDays(bookableDays) : null) ?? - (input.fields ? validateListingFields(input.fields) : null) ?? - (input.durationDays === undefined - ? null - : validateDurationDays(String(input.durationDays))) - ); -}; diff --git a/src/ui/templates/fields.ts b/src/ui/templates/fields.ts index e8fb60a9ab..532968a209 100644 --- a/src/ui/templates/fields.ts +++ b/src/ui/templates/fields.ts @@ -5,6 +5,7 @@ import * as v from "valibot"; import { t } from "#i18n"; import { formatCurrency, getDecimalPlaces } from "#shared/currency.ts"; +import { DAY_NAMES } from "#shared/dates.ts"; import { isUpdateTier } from "#shared/db/built-sites.ts"; import { CONFIG_KEYS, settings } from "#shared/db/settings.ts"; import type { FormParams } from "#shared/form-data.ts"; @@ -15,14 +16,6 @@ import { MAX_IMAGE_SIZE, MAX_TEXTAREA_LENGTH, } from "#shared/limits.ts"; -import { - splitCsv, - VALID_DAY_NAMES, - validateBookableDays, - validateDurationDays, - validateListingFields, - validateListingType, -} from "#shared/listing-field-validators.ts"; import { mergeListingFields, parseListingFields, @@ -38,6 +31,8 @@ import { type AdminLevel, type ContactField, type ContactInfo, + isContactField, + isListingType, type ListingFields, type ListingType, MAX_DURATION_DAYS, @@ -251,21 +246,61 @@ const getUsernameFieldBase = (): Field => ({ type: "text", }); -/** Re-exported so existing importers keep resolving these listing-field - * validators through the field module. */ -export { - splitCsv, - VALID_DAY_NAMES, - validateBookableDays, - validateDurationDays, - validateListingFields, - validateListingType, +/** Validate listing fields setting (comma-separated contact field names) */ +const validateListingFields = (value: string): string | null => { + const parts = value + .split(",") + .map((v) => v.trim()) + .filter((v) => v); + for (const part of parts) { + if (!isContactField(part)) { + return t("fields.validation.invalid_contact_field", { part }); + } + } + return null; +}; + +/** Validate listing type setting */ +const validateListingType = (value: string): string | null => { + if (!isListingType(value)) { + return t("fields.validation.listing_type"); + } + return null; }; /** Validate a built site's update channel (alpha/beta/release) */ const validateUpdateTier = (value: string): string | null => isUpdateTier(value) ? null : t("fields.validation.update_tier"); +/** Valid day names for bookable_days (Monday-first for display) */ +export const VALID_DAY_NAMES = [...DAY_NAMES.slice(1), DAY_NAMES[0]!]; + +/** Check if a string is a valid day name */ +const isValidDayName = (s: string): boolean => + (VALID_DAY_NAMES as readonly string[]).includes(s); + +/** Split a comma-separated string into trimmed, non-empty tokens */ +export const splitCsv = (value: string): string[] => + value + .split(",") + .map((d) => d.trim()) + .filter((d) => d); + +/** Validate bookable days (comma-separated day names) */ +export const validateBookableDays = (value: string): string | null => { + const days = splitCsv(value); + if (days.length === 0) return t("fields.validation.days_required"); + for (const day of days) { + if (!isValidDayName(day)) { + return t("fields.validation.invalid_day", { + day, + valid: VALID_DAY_NAMES.join(", "), + }); + } + } + return null; +}; + /** Shared formatting hint linking to the admin guide */ // Links to the standalone formatting-help page (not the staff-only full guide), // so the hint works for every content role that edits markdown — including @@ -293,7 +328,7 @@ const buildDescriptionField = (hint: string, hintHtml?: string): Field => ({ }); /** Validate a datetime value is parseable */ -export const validateDatetime = (value: string): string | null => +const validateDatetime = (value: string): string | null => isValidDatetime(value) ? null : t("fields.validation.datetime"); /** Build a "hidden" visibility checkbox field for a listing or group. */ @@ -411,7 +446,19 @@ export const getListingFields = (): Field[] => [ min: 1, name: "duration_days", type: "number", - validate: validateDurationDays, + validate: (value: string): string | null => { + // validateSingleField only calls this when the value is non-empty, so + // the empty-string case never reaches here. + const parsed = Number(value); + if (!Number.isInteger(parsed)) { + return t("fields.validation.duration_whole"); + } + if (parsed < 1) return t("fields.validation.duration_min"); + if (parsed > MAX_DURATION_DAYS) { + return t("fields.validation.duration_max", { max: MAX_DURATION_DAYS }); + } + return null; + }, }, { hint: t("fields.listing.customisable_days_hint"), diff --git a/test/lib/catalog-transfer-fields.test.ts b/test/lib/catalog-transfer-fields.test.ts index e4acce4556..09f01e936a 100644 --- a/test/lib/catalog-transfer-fields.test.ts +++ b/test/lib/catalog-transfer-fields.test.ts @@ -81,6 +81,15 @@ describeWithEnv("catalog-transfer field validation", { db: true }, () => { ); }); + test("rejects a datetime with trailing junk", async () => { + // A valid prefix followed by garbage ("…T00:00not-a-zone") must be a field + // error, not stored as an empty datetime. + await expectListingImportError( + { closesAt: "2030-01-01T00:00not-a-zone", maxAttendees: 1, name: "Junk" }, + "closesAt", + ); + }); + test("accepts an explicitly empty closesAt (never closes)", async () => { const result = await importCatalog({ kind: "listing", From 853da9e4d5dd17929e249e52833072823f58c7da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:37:15 +0000 Subject: [PATCH 15/28] Import: clear non-package overrides, reject hidden-package parents, disable in demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more Codex import-boundary fixes: - Clear package price/quantity/day-price overrides for any membership whose group isn't a package (listing import, per group) or whose imported group blob isn't a package — matching the normal group save, so a blob can't plant a hidden free price that activates if the group is later converted. - Reject a parent that is a hidden-package member (batched via getGroupIdsByListingIds + the cached group set, no N+1), mirroring the edge editor's packageChildEdgeConflict rule. - Disable catalog import in demo mode, since a raw blob bypasses the form's demo-field scrubbing and webhook clearing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/import.ts | 82 ++++++++++++++----- src/features/admin/catalog-transfer/routes.ts | 8 ++ src/locales/en/catalog-transfer.json | 3 +- test/lib/catalog-transfer.test.ts | 53 ++++++++++++ test/lib/server-catalog-transfer.test.ts | 24 ++++++ 5 files changed, 149 insertions(+), 21 deletions(-) diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index 0f9df59fff..b777c08fb7 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -20,7 +20,12 @@ import { generateUniqueGroupSlug, } from "#routes/admin/groups.ts"; import { writeRowInTransaction } from "#shared/db/client.ts"; -import { type GroupInput, groupsTable } from "#shared/db/groups.ts"; +import { + type GroupInput, + getAllGroups, + getGroupIdsByListingIds, + groupsTable, +} from "#shared/db/groups.ts"; import { addParentEdgesTx, getChildListingIds, @@ -141,16 +146,26 @@ const firstPackageGroup = async ( return null; }; -/** Read the shared package-override fields off a membership/member entry. */ -const membershipSpec = (entry: { - packagePrice?: number | null | undefined; - quantity?: number | undefined; - dayPrices?: Record | undefined; -}): MembershipSpec => ({ - dayPrices: entry.dayPrices ? parseDayPrices(entry.dayPrices) : {}, - packagePrice: entry.packagePrice ?? null, - quantity: entry.quantity ?? 1, -}); +/** Read the shared package-override fields off a membership/member entry. + * `isPackage` false clears every override (price/quantity/day prices) — those + * only apply to a package group, and the normal group save drops them when a + * group isn't a package, so a blob can't plant a hidden free price or quantity + * that silently activates if the group is later converted. */ +const membershipSpec = ( + entry: { + packagePrice?: number | null | undefined; + quantity?: number | undefined; + dayPrices?: Record | undefined; + }, + isPackage: boolean, +): MembershipSpec => + isPackage + ? { + dayPrices: entry.dayPrices ? parseDayPrices(entry.dayPrices) : {}, + packagePrice: entry.packagePrice ?? null, + quantity: entry.quantity ?? 1, + } + : { dayPrices: {}, packagePrice: null, quantity: 1 }; /** Project a validated listing blob onto a `ListingInput`, minting a fresh slug * and clearing the (non-transferred) image/attachment columns. Optional fields @@ -202,13 +217,21 @@ const validateParentEdges = async ( return `"${input.name}" is a member of the package "${pkg.name}", so it cannot also be an add-on child of another listing.`; } const childEdge = listingInputToEdge(input, 0); - // Two batched reads (never one query per parent): the listing rows, and the - // subset of parents that are themselves children — a many-parent import must - // not trip the request's N+1 guard. - const [byId, nestedParents] = await Promise.all([ + // Batched reads (never one query per parent, so a many-parent import can't + // trip the request's N+1 guard): the listing rows, the parents that are + // themselves children, each parent's group ids, and the whole (cached) group + // set to identify hidden packages. + const [byId, nestedParents, parentGroupIds, allGroups] = await Promise.all([ getListingsById(), getChildListingIds(parentIds), + getGroupIdsByListingIds([...parentIds]), + getAllGroups(), ]); + const hiddenPackageIds = new Set( + allGroups + .filter((g) => g.is_package && g.hide_package_listings) + .map((g) => g.id), + ); for (const parentId of parentIds) { // parentIds were resolved by name from the same cached catalog byId reads, // so every id is present (trust the invariant rather than guard a dead path). @@ -220,6 +243,14 @@ const validateParentEdges = async ( name: parent.name, }); } + // A hidden-package member is collapsed on buyer surfaces and can't render a + // child selector, so it may not gain children — the same rule the edge + // editor enforces via packageChildEdgeConflict. + if ( + (parentGroupIds.get(parentId) ?? []).some((g) => hiddenPackageIds.has(g)) + ) { + return `"${parent.name}" is a member of a hidden package, so it cannot offer add-on children.`; + } const error = edgeFieldError(listingToEdge(parent), childEdge); if (error) return error; } @@ -284,10 +315,18 @@ const importListing = async ( ); if (edgeError) return fail(edgeError); - const specs = memberships.map((m, i) => ({ - ...membershipSpec(m), - groupId: groupResolve.ids[i]!, - })); + // Package overrides only apply to a package group; clear them for any regular + // group the listing joins (matching the normal group save). + const packageGroupIds = new Set( + (await getAllGroups()).filter((g) => g.is_package).map((g) => g.id), + ); + const specs = memberships.map((m, i) => { + const groupId = groupResolve.ids[i]!; + return { + ...membershipSpec(m, packageGroupIds.has(groupId)), + groupId, + }; + }); const id = await writeRowInTransaction( await listingsTable.insertStatement!(input), null, @@ -361,8 +400,11 @@ const importGroup = async (transfer: GroupTransfer): Promise => { // Cast bridges valibot's `T | undefined` optionals to GroupInput's exact // optionals; members are written separately (not via GroupInput.packageMembers). const input = { ...group, slug, slugIndex } as GroupInput; + // Package overrides only apply to a package group; a non-package group clears + // them (matching the normal group save). + const isPackage = group.isPackage ?? false; const specs = members.map((m, i) => ({ - ...membershipSpec(m), + ...membershipSpec(m, isPackage), listingId: memberResolve.ids[i]!, })); const id = await writeRowInTransaction( diff --git a/src/features/admin/catalog-transfer/routes.ts b/src/features/admin/catalog-transfer/routes.ts index ac2c454d18..7c502ee156 100644 --- a/src/features/admin/catalog-transfer/routes.ts +++ b/src/features/admin/catalog-transfer/routes.ts @@ -24,6 +24,7 @@ import { } from "#routes/response.ts"; import { defineRoutes, type TypedRouteHandler } from "#routes/router.ts"; import { logActivity } from "#shared/db/activityLog.ts"; +import { isDemoMode } from "#shared/demo.ts"; import { adminCatalogImportPage } from "#templates/admin/catalog-transfer.tsx"; import { exportGroup, exportListing } from "./export.ts"; import { importCatalog } from "./import.ts"; @@ -106,6 +107,13 @@ const handleImportPost: TypedRouteHandler<"POST /admin/catalog/import"> = ( request, ) => withAuth(request, CONTENT_MULTIPART, async (session, formData) => { + // The interactive create paths scrub demo-mapped fields (and clear webhook + // URLs) as they parse the form; a raw import blob bypasses all of that, so + // disable catalog import entirely in demo mode rather than persist arbitrary + // names/descriptions/locations or an external webhook into a public demo. + if (isDemoMode()) { + return errorRedirect(IMPORT_PATH, t("catalog_transfer.demo_disabled")); + } const file = formData.get("catalog_file"); if (!(file instanceof File) || file.size === 0) { return errorRedirect(IMPORT_PATH, t("catalog_transfer.no_file")); diff --git a/src/locales/en/catalog-transfer.json b/src/locales/en/catalog-transfer.json index 8e0fc1e7c3..373c5a543c 100644 --- a/src/locales/en/catalog-transfer.json +++ b/src/locales/en/catalog-transfer.json @@ -9,5 +9,6 @@ "catalog_transfer.no_file": "Please select a JSON file to import.", "catalog_transfer.invalid_json": "The file is not valid JSON.", "catalog_transfer.imported_listing": "Imported listing {name}", - "catalog_transfer.imported_group": "Imported group {name}" + "catalog_transfer.imported_group": "Imported group {name}", + "catalog_transfer.demo_disabled": "Catalog import is disabled in demo mode." } diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index b28df54b84..d8eabb5639 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -462,6 +462,59 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect((await getListing(result.id))!.uses_logistics).toBe(true); }); + test("clears package overrides for a non-package group membership", async () => { + const group = await createTestGroup({ name: "Regular Group" }); + const result = await importCatalog({ + groups: [{ group: "Regular Group", packagePrice: 500, quantity: 3 }], + kind: "listing", + listing: { maxAttendees: 1, name: "Joiner", unitPrice: 900 }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const row = (await getGroupPackagePrices(group.id)).find( + (r) => r.listing_id === result.id, + )!; + // The override is dropped because the group isn't a package. + expect(row.package_price).toBeNull(); + expect(row.quantity).toBe(1); + }); + + test("clears member overrides when importing a non-package group", async () => { + await createTestListing({ name: "Plain Member", unitPrice: 800 }); + const result = await importCatalog({ + group: { name: "Plain Group" }, + kind: "group", + members: [{ listing: "Plain Member", packagePrice: 200, quantity: 5 }], + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const row = (await getGroupPackagePrices(result.id))[0]!; + expect(row.package_price).toBeNull(); + expect(row.quantity).toBe(1); + }); + + test("rejects a parent that is a hidden-package member", async () => { + const pkg = await createTestGroup({ isPackage: true, name: "Hidden Pkg" }); + // createTestGroup can't set hide_package_listings, so set it directly and + // refresh the cached group set the import reads. + await execute("UPDATE groups SET hide_package_listings = 1 WHERE id = ?", [ + pkg.id, + ]); + const { invalidateGroupsCache } = await import("#shared/db/groups.ts"); + invalidateGroupsCache(); + const parent = await createTestListing({ name: "Pkg Parent" }); + await assignListingsToGroup([parent.id], pkg.id); + const result = await importCatalog({ + kind: "listing", + listing: { maxAttendees: 1, name: "Kid" }, + parents: ["Pkg Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("hidden package"); + }); + test("imports a listing with many parents in one batched insert", async () => { // More parents than the per-request N+1 guard (25) and the transaction // round-trip cap (~30) would allow one query/insert each — proves the diff --git a/test/lib/server-catalog-transfer.test.ts b/test/lib/server-catalog-transfer.test.ts index 609ef6f7dd..b3d586426d 100644 --- a/test/lib/server-catalog-transfer.test.ts +++ b/test/lib/server-catalog-transfer.test.ts @@ -101,6 +101,30 @@ describeWithEnv("server (catalog transfer)", { db: true }, () => { method: "POST", }); + test("is disabled in demo mode", async () => { + const { setDemoModeForTest } = await import("#shared/demo.ts"); + setDemoModeForTest(true); + try { + const response = await importUpload({ + group: { name: "Demo Group" }, + kind: "group", + members: [], + version: 1, + }); + await expectFlashRedirect( + "/admin/catalog/import", + "Catalog import is disabled in demo mode.", + false, + )(response); + // Nothing was imported. + expect( + (await getAllListings()).some((l) => l.name === "Demo Group"), + ).toBe(false); + } finally { + setDemoModeForTest(false); + } + }); + test("creates a listing from an uploaded blob and redirects", async () => { const group = await createTestGroup({ name: "Host Group" }); const response = await importUpload({ From c7f359f8b07be2e6043281ebbcb6182c593f1c1b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 00:49:11 +0000 Subject: [PATCH 16/28] Batch group-compat validation so many-group imports stay under the N+1 guard Importing a listing that belongs to many groups routed through validateListingInput, whose group check ran one sibling SELECT per group via validateGroupListingType. Past ~25 groups the per-request N+1 read guard tripped before any write, so a valid export of a group-heavy listing could not be imported. validateListingGroup now batches those reads: one cached getAllGroups() plus one getListingsByGroupIds() for every referenced group, with the homogeneity check run in memory per group via a new groupListingTypeError helper (the pure core validateGroupListingType now delegates to). getListingsByGroupIds is the batched, inactive-inclusive form of getListingsByGroupId; getActiveListingsByGroupIds becomes a thin wrapper over it. Reads are now constant regardless of group count, and the change is behaviour-preserving for the form and API create/edit paths. Covered by an "imports a listing that belongs to many groups" test (30 groups). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/shared/db/groups.ts | 48 +++++++++++++++++++++++++------ src/shared/listings-actions.ts | 21 ++++++++++---- test/lib/catalog-transfer.test.ts | 16 +++++++++++ 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 214fc152ee..2713d403d4 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -185,17 +185,23 @@ export const groupExists = async (id: number): Promise => [id], )) !== null; -/** Active listings of several groups in two bounded queries, keyed by group id - * (a group with no active member gets no entry). The public nav's one-shot - * liveness read — never one query per group. Membership is many-to-many +/** Listings of several groups in two bounded queries, keyed by group id (a group + * with no matching member gets no entry). The batched form of + * {@link getListingsByGroupId} — never one query per group, so a caller checking + * many groups (e.g. group-compatibility validation for a listing that joins many + * groups) stays under the N+1 read guard. Membership is many-to-many * (`group_listings`), so a listing appears under EVERY requested group it - * belongs to. Empty input ⇒ no query. */ -export const getActiveListingsByGroupIds = async ( + * belongs to. Empty input ⇒ no query. `activeOnly` restricts to active listings + * (the public nav's liveness read); the default includes inactive (the + * validators' compatibility read). */ +export const getListingsByGroupIds = async ( groupIds: readonly number[], + activeOnly = false, ): Promise> => { if (groupIds.length === 0) return new Map(); + const activeClause = activeOnly ? "listing.active = 1 AND " : ""; const rows = await queryListingsWithCounts( - `WHERE listing.active = 1 AND listing.id IN + `WHERE ${activeClause}listing.id IN (SELECT listing_id FROM group_listings WHERE group_id IN (${inPlaceholders(groupIds)}))`, [...groupIds], ); @@ -214,6 +220,13 @@ export const getActiveListingsByGroupIds = async ( return byGroup; }; +/** Active listings of several groups, keyed by group id — the public nav's + * one-shot liveness read. */ +export const getActiveListingsByGroupIds = ( + groupIds: readonly number[], +): Promise> => + getListingsByGroupIds(groupIds, true); + /** * Get all listings in a group with attendee counts (including inactive). */ @@ -234,8 +247,27 @@ export const validateGroupListingType = async ( listingType: ListingType, customisableDays: boolean, excludeListingId?: number, -): Promise => { - const allSiblings = await getListingsByGroupId(groupId); +): Promise => + groupListingTypeError( + await getListingsByGroupId(groupId), + listingType, + customisableDays, + excludeListingId, + ); + +/** + * The in-memory core of {@link validateGroupListingType}: given a group's + * already-loaded members, return the homogeneity error (or null). Callers that + * validate many groups at once batch the member reads (see + * {@link getListingsByGroupIds}) and drive this directly, so they never issue + * one sibling query per group and trip the N+1 read guard. + */ +export const groupListingTypeError = ( + allSiblings: readonly ListingWithCount[], + listingType: ListingType, + customisableDays: boolean, + excludeListingId?: number, +): string | null => { const siblings = allSiblings.filter((e) => e.id !== excludeListingId); const typeMismatch = siblings.find((e) => e.listing_type !== listingType); if (typeMismatch) { diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index d1a0680222..b2134efe23 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -9,9 +9,10 @@ import { t } from "#i18n"; import { formatCurrency } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { + getAllGroups, getGroupIdsByListingIds, - groupsTable, - validateGroupListingType, + getListingsByGroupIds, + groupListingTypeError, } from "#shared/db/groups.ts"; import { edgeIdsTouching, @@ -101,16 +102,24 @@ const packageMembershipError = async ( }; const validateListingGroup: ListingUpdateCheck = async (input, existingId) => { + const groupIds = input.groupIds ?? []; + if (groupIds.length === 0) return null; // Only pay-what-you-want pricing is package-incompatible: a package needs an // operator-set price per member. Daily/customisable members are packageable // (the group keeps members homogeneous, sharing one date/day-count selector). const incompatibleByType = input.canPayMore ?? false; - for (const groupId of input.groupIds ?? []) { - const group = await groupsTable.findById(groupId); + // Batch the per-group reads so a listing that joins many groups (e.g. a + // catalog import of a listing exported from a group-heavy site) stays under + // the N+1 read guard: one cached groups load plus one sibling query for all + // referenced groups, then the compatibility check runs in memory per group. + const groupsById = new Map((await getAllGroups()).map((g) => [g.id, g])); + const siblingsByGroup = await getListingsByGroupIds(groupIds); + for (const groupId of groupIds) { + const group = groupsById.get(groupId); if (!group) return "Selected group does not exist"; - const typeError = await validateGroupListingType( - groupId, + const typeError = groupListingTypeError( + siblingsByGroup.get(groupId) ?? [], // The DB column defaults to "standard" when omitted (e.g. a JSON API // create that sends group_ids but no listing_type), so validate against // that default rather than passing undefined and reading every standard diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index d8eabb5639..c63cde32dc 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -543,6 +543,22 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect((await getParentIds(result.id)).length).toBe(30); }); + test("imports a listing that belongs to many groups", async () => { + // More groups than the per-request N+1 guard (25) would allow one + // compatibility SELECT each — proves group-compat validation batch-loads the + // siblings for every referenced group instead of one query per group. + const groupNames = Array.from({ length: 30 }, (_, i) => `Group ${i}`); + for (const name of groupNames) await createTestGroup({ name }); + const result = await importCatalog({ + groups: groupNames.map((group) => ({ group })), + kind: "listing", + listing: { maxAttendees: 1, name: "Joins Many" }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getGroupIdsByListingId(result.id)).length).toBe(30); + }); + test("hides the webhook URL from an editor export", async () => { const listing = await createTestListing({ name: "Hooked", From c062e72f8f1202f0f874256177a6a15a12e53567 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:16:06 +0000 Subject: [PATCH 17/28] Fix many-group import test; reject bad datetime offsets; batch package-group check Three follow-ups from review on the prior commit: - The "imports a listing that belongs to many groups" test created its 30 groups via createTestGroup, whose trailing getAllGroups() re-reads the cache (invalidated by each create) 30 times in one test context and trips the N+1 read guard during setup. Build the groups with a direct groupsTable.insert instead, mirroring the many-parents test. - isStorableDatetime accepted out-of-range timezone offsets like "+99:99" because the offset digits were never range-checked; the storage normaliser then treats the whole value as invalid and silently empties the column. Capture the offset hours/minutes and reject > 23 / > 59, and cover it with an "out-of-range offset" import test. - firstPackageGroup still did one groupsTable.findById per group, so a child listing that joins many groups could trip the N+1 guard before the batched parent validation. Resolve against the request-cached group set via a new shared getGroupsById() helper (also used by validateListingGroup, replacing its inline map). Covered by an "imports a child listing that also belongs to a regular group" test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/import.ts | 7 +++- src/features/admin/catalog-transfer/schema.ts | 21 +++++++++-- src/shared/db/groups.ts | 6 ++++ src/shared/listings-actions.ts | 4 +-- test/lib/catalog-transfer-fields.test.ts | 9 +++++ test/lib/catalog-transfer.test.ts | 35 +++++++++++++++++-- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index b777c08fb7..bda0d67842 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -24,6 +24,7 @@ import { type GroupInput, getAllGroups, getGroupIdsByListingIds, + getGroupsById, groupsTable, } from "#shared/db/groups.ts"; import { @@ -139,8 +140,12 @@ const withNewId = ( const firstPackageGroup = async ( groupIds: readonly number[], ): Promise => { + if (groupIds.length === 0) return null; + // Resolve against the cached group set rather than one findById per group, so a + // child listing that belongs to many groups doesn't trip the request N+1 guard. + const byId = await getGroupsById(); for (const groupId of groupIds) { - const group = await groupsTable.findById(groupId); + const group = byId.get(groupId); if (group?.is_package) return group; } return null; diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 35cd2ed2cb..fa3620a48f 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -36,9 +36,11 @@ const isStorableDatetime = (value: string): boolean => { if (value === "") return true; // Anchored end ($) so trailing junk ("…T00:00not-a-zone") is rejected rather // than silently emptied by the storage normaliser: optional seconds, optional - // fractional seconds, and an optional Z / ±HH:MM offset are the only tails. + // fractional seconds, and an optional Z / ±HH:MM offset are the only tails. The + // offset hours/minutes are captured so an out-of-range offset ("+99:99") is + // rejected too, not just range-checked on the local time. const m = value.match( - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/, + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))?$/, ); if (!m) return false; const y = Number(m[1]); @@ -47,7 +49,20 @@ const isStorableDatetime = (value: string): boolean => { const h = Number(m[4]); const mi = Number(m[5]); const s = m[6] === undefined ? 0 : Number(m[6]); - if (mo < 1 || mo > 12 || d < 1 || h > 23 || mi > 59 || s > 59) return false; + const oh = m[7] === undefined ? 0 : Number(m[7]); + const om = m[8] === undefined ? 0 : Number(m[8]); + if ( + mo < 1 || + mo > 12 || + d < 1 || + h > 23 || + mi > 59 || + s > 59 || + oh > 23 || + om > 59 + ) { + return false; + } // Round-trip through UTC: a rolled-over impossible date won't match its parts. const dt = new Date(Date.UTC(y, mo - 1, d)); return ( diff --git a/src/shared/db/groups.ts b/src/shared/db/groups.ts index 2713d403d4..88076c7697 100644 --- a/src/shared/db/groups.ts +++ b/src/shared/db/groups.ts @@ -122,6 +122,12 @@ export const invalidateGroupsCache = (): void => groupsCache.invalidate(); */ export const getAllGroups = (): Promise => groupsCache.getAll(); +/** Every group keyed by id, from the request-cached set — the batched + * alternative to one findById per id when resolving or validating many groups + * without tripping the N+1 read guard. */ +export const getGroupsById = async (): Promise> => + new Map((await getAllGroups()).map((g) => [g.id, g])); + /** Narrow id → name map for every group (selects + decrypts only the name), for * pickers/labels that must not load the whole groups cache. */ export const getAllGroupNames = (): Promise> => diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index b2134efe23..dcbe28a9f1 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -9,8 +9,8 @@ import { t } from "#i18n"; import { formatCurrency } from "#shared/currency.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { - getAllGroups, getGroupIdsByListingIds, + getGroupsById, getListingsByGroupIds, groupListingTypeError, } from "#shared/db/groups.ts"; @@ -112,7 +112,7 @@ const validateListingGroup: ListingUpdateCheck = async (input, existingId) => { // catalog import of a listing exported from a group-heavy site) stays under // the N+1 read guard: one cached groups load plus one sibling query for all // referenced groups, then the compatibility check runs in memory per group. - const groupsById = new Map((await getAllGroups()).map((g) => [g.id, g])); + const groupsById = await getGroupsById(); const siblingsByGroup = await getListingsByGroupIds(groupIds); for (const groupId of groupIds) { const group = groupsById.get(groupId); diff --git a/test/lib/catalog-transfer-fields.test.ts b/test/lib/catalog-transfer-fields.test.ts index 09f01e936a..5180226a5f 100644 --- a/test/lib/catalog-transfer-fields.test.ts +++ b/test/lib/catalog-transfer-fields.test.ts @@ -90,6 +90,15 @@ describeWithEnv("catalog-transfer field validation", { db: true }, () => { ); }); + test("rejects a datetime with an out-of-range offset", async () => { + // A well-formed prefix with an impossible timezone offset ("+99:99") must be + // a field error, not stored as an empty datetime by the storage normaliser. + await expectListingImportError( + { closesAt: "2030-01-01T00:00+99:99", maxAttendees: 1, name: "Offset" }, + "closesAt", + ); + }); + test("accepts an explicitly empty closesAt (never closes)", async () => { const result = await importCatalog({ kind: "listing", diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index c63cde32dc..522c7381d3 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -546,9 +546,22 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { test("imports a listing that belongs to many groups", async () => { // More groups than the per-request N+1 guard (25) would allow one // compatibility SELECT each — proves group-compat validation batch-loads the - // siblings for every referenced group instead of one query per group. + // siblings for every referenced group instead of one query per group. Insert + // the groups directly (like the many-parents case) rather than via + // createTestGroup, whose trailing getAllGroups() would itself trip the read + // guard 30 times over in this one test context. + const { computeGroupSlugIndex, groupsTable } = await import( + "#shared/db/groups.ts" + ); const groupNames = Array.from({ length: 30 }, (_, i) => `Group ${i}`); - for (const name of groupNames) await createTestGroup({ name }); + for (const name of groupNames) { + const slug = name.toLowerCase().replace(/\s+/g, "-"); + await groupsTable.insert({ + name, + slug, + slugIndex: await computeGroupSlugIndex(slug), + }); + } const result = await importCatalog({ groups: groupNames.map((group) => ({ group })), kind: "listing", @@ -559,6 +572,24 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { expect((await getGroupIdsByListingId(result.id)).length).toBe(30); }); + test("imports a child listing that also belongs to a regular group", async () => { + // A child (has a parent) that joins a non-package group exercises the + // package-membership guard over a real, non-package group set — it must find + // no package and let the parent edge through. + await createTestListing({ name: "Edge Parent" }); + await createTestGroup({ name: "Plain Joiner Group" }); + const result = await importCatalog({ + groups: [{ group: "Plain Joiner Group" }], + kind: "listing", + listing: { maxAttendees: 1, name: "Grouped Kid" }, + parents: ["Edge Parent"], + version: 1, + }); + if (!result.ok) throw new Error(result.error); + expect((await getParentIds(result.id)).length).toBe(1); + expect((await getGroupIdsByListingId(result.id)).length).toBe(1); + }); + test("hides the webhook URL from an editor export", async () => { const listing = await createTestListing({ name: "Hooked", From 6c945835cddeddc616910812bd1caf9931d6a7dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:37:27 +0000 Subject: [PATCH 18/28] Reject fractional datetimes without a seconds component on import The datetime regex placed the optional fractional-seconds group outside the optional seconds group, so "2030-01-01T00:00.123Z" (a fraction with no seconds) passed validation; the storage normaliser then treats the whole value as invalid and silently empties the date/closesAt column. Tie the fractional part to the seconds group so a fraction is only accepted after seconds. Covered by "rejects fractional seconds without a seconds component" and a positive "accepts fractional seconds after a seconds component" test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/schema.ts | 11 +++++----- test/lib/catalog-transfer-fields.test.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index fa3620a48f..c0bda73212 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -35,12 +35,13 @@ import { const isStorableDatetime = (value: string): boolean => { if (value === "") return true; // Anchored end ($) so trailing junk ("…T00:00not-a-zone") is rejected rather - // than silently emptied by the storage normaliser: optional seconds, optional - // fractional seconds, and an optional Z / ±HH:MM offset are the only tails. The - // offset hours/minutes are captured so an out-of-range offset ("+99:99") is - // rejected too, not just range-checked on the local time. + // than silently emptied by the storage normaliser: optional seconds (with + // optional fractional seconds only *after* seconds — "T00:00.123" is not a real + // instant), and an optional Z / ±HH:MM offset are the only tails. The offset + // hours/minutes are captured so an out-of-range offset ("+99:99") is rejected + // too, not just range-checked on the local time. const m = value.match( - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))?$/, + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-](\d{2}):(\d{2}))?$/, ); if (!m) return false; const y = Number(m[1]); diff --git a/test/lib/catalog-transfer-fields.test.ts b/test/lib/catalog-transfer-fields.test.ts index 5180226a5f..5a5c9123e0 100644 --- a/test/lib/catalog-transfer-fields.test.ts +++ b/test/lib/catalog-transfer-fields.test.ts @@ -99,6 +99,28 @@ describeWithEnv("catalog-transfer field validation", { db: true }, () => { ); }); + test("rejects fractional seconds without a seconds component", async () => { + // Fractional seconds are only meaningful after a seconds field; a bare + // "T00:00.123Z" is not a real instant and the storage layer would empty it. + await expectListingImportError( + { closesAt: "2030-01-01T00:00.123Z", maxAttendees: 1, name: "Frac" }, + "closesAt", + ); + }); + + test("accepts fractional seconds after a seconds component", async () => { + const result = await importCatalog({ + kind: "listing", + listing: { + closesAt: "2030-01-01T00:00:00.500Z", + maxAttendees: 1, + name: "Frac OK", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + }); + test("accepts an explicitly empty closesAt (never closes)", async () => { const result = await importCatalog({ kind: "listing", From 37e0c2ae543d51bdc3fb66b30fe492e183b7fd15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:55:55 +0000 Subject: [PATCH 19/28] Reject unsafe-integer prices in catalog imports intAtLeast only checked integer-ness and the lower bound, so a price like 1e100 (an "integer" to Number.isInteger but outside the safe range) passed and would be rounded or throw a raw error at the storage layer. Require v.safeInteger() in the shared intAtLeast helper (and DurationDaysSchema), which covers prices, quantities, counts, and durations at once, matching the form's money parser. Covered by a "rejects a price above the safe-integer range" test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/schema.ts | 9 ++++++--- test/lib/catalog-transfer-fields.test.ts | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index c0bda73212..a7289fcde2 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -83,9 +83,12 @@ const DatetimeSchema = v.pipe( * rejected with an intelligible message rather than mis-imported. */ export const CATALOG_TRANSFER_VERSION = 1; -/** A whole integer of at least `min`. */ +/** A whole integer of at least `min`. Uses `safeInteger` (not just `integer`) + * so an out-of-safe-range magnitude like `1e100` — which `Number.isInteger` + * accepts — is a field error here rather than being rounded or throwing a raw + * error at the storage layer, matching the form's money parser. */ const intAtLeast = (min: number) => - v.pipe(v.number(), v.integer(), v.minValue(min)); + v.pipe(v.number(), v.safeInteger(), v.minValue(min)); /** A whole non-negative integer (counts, day windows, minor-unit prices). */ const NonNegativeIntSchema = intAtLeast(0); /** A whole positive integer (durations, quantities). */ @@ -94,7 +97,7 @@ const PositiveIntSchema = intAtLeast(1); * form's cap so an over-limit blob is a field error, not silently clamped. */ const DurationDaysSchema = v.pipe( v.number(), - v.integer(), + v.safeInteger(), v.minValue(1), v.maxValue(MAX_DURATION_DAYS, `must be at most ${MAX_DURATION_DAYS} days`), ); diff --git a/test/lib/catalog-transfer-fields.test.ts b/test/lib/catalog-transfer-fields.test.ts index 5a5c9123e0..0e8cc88cbf 100644 --- a/test/lib/catalog-transfer-fields.test.ts +++ b/test/lib/catalog-transfer-fields.test.ts @@ -99,6 +99,16 @@ describeWithEnv("catalog-transfer field validation", { db: true }, () => { ); }); + test("rejects a price above the safe-integer range", async () => { + // 1e100 is an "integer" to Number.isInteger but not safe; the money layer + // rejects such minor-unit amounts, so the import must too rather than round + // it or throw a raw storage error. + await expectListingImportError( + { maxAttendees: 1, name: "Rich", unitPrice: 1e100 }, + "unitPrice", + ); + }); + test("rejects fractional seconds without a seconds component", async () => { // Fractional seconds are only meaningful after a seconds field; a bare // "T00:00.123Z" is not a real instant and the storage layer would empty it. From 7e91714b23f70298aebe639dd3bd2d61ea311b56 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 08:11:20 +0000 Subject: [PATCH 20/28] Reject package day-price overrides for spans a member doesn't offer on import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package editor only renders day-price override inputs for a member's available day counts (a customisable listing's priced spans within its duration). A raw import bypassed that, persisting a group_day override for any day count in the global 1..90 range — a hidden row that could activate after a later duration/day-price edit. Add memberDayOverrideError, reused on both membership sides: a group import validates each existing member's overrides, and a listing import validates the new listing's own group overrides, against availableDayCounts. Out-of- range overrides are now a field-level error. The tests live in a new catalog-transfer-packages.test.ts (creating customisable members is read-heavy; folding them into catalog-transfer.test.ts tips that file past the per-request N+1 read guard). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/import.ts | 54 +++++++++++++ test/lib/catalog-transfer-packages.test.ts | 80 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/lib/catalog-transfer-packages.test.ts diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index bda0d67842..ae91d7cee5 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -57,6 +57,8 @@ import { } from "#shared/listings-actions.ts"; import { type AdminLevel, + availableDayCounts, + type DayPricedListing, type Group, type Listing, type ListingType, @@ -172,6 +174,29 @@ const membershipSpec = ( } : { dayPrices: {}, packagePrice: null, quantity: 1 }; +/** Reject a package day-price override for a day count the member doesn't offer. + * The package editor only renders override inputs for a member's available day + * counts (a customisable listing's priced spans within its duration); a blob + * override outside them would be a hidden `group_day` row that could activate + * after a later duration/day-price edit, so refuse it with a field-level + * message. `member` is the listing whose spans the override must fit — the + * existing member on a group import, the new listing itself on a listing + * import. */ +const memberDayOverrideError = ( + memberName: string, + dayPrices: Record | undefined, + member: DayPricedListing, +): string | null => { + if (!dayPrices) return null; + const offered = new Set(availableDayCounts(member)); + for (const key of Object.keys(dayPrices)) { + if (!offered.has(Number(key))) { + return `"${memberName}" does not offer a ${key}-day booking, so it can't carry a package day-price override for it.`; + } + } + return null; +}; + /** Project a validated listing blob onto a `ListingInput`, minting a fresh slug * and clearing the (non-transferred) image/attachment columns. Optional fields * pass through untouched so the table applies its own column defaults. */ @@ -325,6 +350,22 @@ const importListing = async ( const packageGroupIds = new Set( (await getAllGroups()).filter((g) => g.is_package).map((g) => g.id), ); + // A package day-price override must target a day count this listing offers — + // the new listing itself is the member here, so validate against its own spans. + const newMember: DayPricedListing = { + customisable_days: input.customisableDays ?? false, + day_prices: input.dayPrices ?? {}, + duration_days: input.durationDays ?? 1, + }; + for (let i = 0; i < memberships.length; i++) { + if (!packageGroupIds.has(groupResolve.ids[i]!)) continue; + const dayError = memberDayOverrideError( + listing.name, + memberships[i]!.dayPrices, + newMember, + ); + if (dayError) return fail(dayError); + } const specs = memberships.map((m, i) => { const groupId = groupResolve.ids[i]!; return { @@ -408,6 +449,19 @@ const importGroup = async (transfer: GroupTransfer): Promise => { // Package overrides only apply to a package group; a non-package group clears // them (matching the normal group save). const isPackage = group.isPackage ?? false; + // Each member's day-price overrides must target a day count that member offers. + if (isPackage) { + const listingById = new Map(listings.map((l) => [l.id, l])); + for (let i = 0; i < members.length; i++) { + const member = listingById.get(memberResolve.ids[i]!)!; + const dayError = memberDayOverrideError( + member.name, + members[i]!.dayPrices, + member, + ); + if (dayError) return fail(dayError); + } + } const specs = members.map((m, i) => ({ ...membershipSpec(m, isPackage), listingId: memberResolve.ids[i]!, diff --git a/test/lib/catalog-transfer-packages.test.ts b/test/lib/catalog-transfer-packages.test.ts new file mode 100644 index 0000000000..fb17bdfb84 --- /dev/null +++ b/test/lib/catalog-transfer-packages.test.ts @@ -0,0 +1,80 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { + createTestGroup, + createTestListing, + describeWithEnv, +} from "#test-utils"; + +// Package day-price override validation lives in its own file: importing a +// customisable member exercises the read-heavy create helpers, and folding +// these into catalog-transfer.test.ts would tip that file's per-request read +// count past the N+1 guard. + +/** The customisable-listing config every case shares: offers 1- and 2-day + * bookings (so a 5-day override is out of range). */ +const customisable = { + customisableDays: true, + dayPrices: { 1: 1000, 2: 1800 }, + durationDays: 2, + listingType: "daily", +} as const; + +/** Import a customisable listing that joins package group `group` with a single + * `overrideDay` package day-price override. */ +const importListingOverride = ( + name: string, + group: string, + overrideDay: number, +) => + importCatalog({ + groups: [{ dayPrices: { [overrideDay]: 500 }, group }], + kind: "listing", + listing: { ...customisable, maxAttendees: 1, name }, + version: 1, + }); + +describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { + test("rejects a package member day-override for an unoffered span", async () => { + // The member offers 1- and 2-day bookings; a 5-day override is a span it + // doesn't have, so the package editor would never render that input. + await createTestListing({ ...customisable, name: "Custom Member" }); + const result = await importCatalog({ + group: { isPackage: true, name: "Pkg Custom" }, + kind: "group", + members: [{ dayPrices: { 5: 500 }, listing: "Custom Member" }], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("5-day"); + }); + + test("accepts a package member day-override for an offered span", async () => { + await createTestListing({ ...customisable, name: "Custom Member 2" }); + const result = await importCatalog({ + group: { isPackage: true, name: "Pkg Custom 2" }, + kind: "group", + members: [{ dayPrices: { 2: 500 }, listing: "Custom Member 2" }], + version: 1, + }); + if (!result.ok) throw new Error(result.error); + }); + + test("rejects a listing-import package day-override for an unoffered span", async () => { + // The new listing offers 1- and 2-day bookings; its package membership can't + // carry a 5-day override. + await createTestGroup({ isPackage: true, name: "Host Pkg" }); + const result = await importListingOverride("Custom Joiner", "Host Pkg", 5); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + expect(result.error).toContain("5-day"); + }); + + test("accepts a listing-import package day-override for an offered span", async () => { + await createTestGroup({ isPackage: true, name: "Host Pkg 2" }); + const result = await importListingOverride("Custom Joiner 2", "Host Pkg 2", 2); + if (!result.ok) throw new Error(result.error); + }); +}); From d73f2de41147e2543a3c45c6cad80da8fe0e03e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 08:12:55 +0000 Subject: [PATCH 21/28] Format catalog-transfer-packages test (biome) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- test/lib/catalog-transfer-packages.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/lib/catalog-transfer-packages.test.ts b/test/lib/catalog-transfer-packages.test.ts index fb17bdfb84..66b950b569 100644 --- a/test/lib/catalog-transfer-packages.test.ts +++ b/test/lib/catalog-transfer-packages.test.ts @@ -74,7 +74,11 @@ describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { test("accepts a listing-import package day-override for an offered span", async () => { await createTestGroup({ isPackage: true, name: "Host Pkg 2" }); - const result = await importListingOverride("Custom Joiner 2", "Host Pkg 2", 2); + const result = await importListingOverride( + "Custom Joiner 2", + "Host Pkg 2", + 2, + ); if (!result.ok) throw new Error(result.error); }); }); From a60d936bc4cd163ae54501f84162bfc8baa05693 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 08:27:20 +0000 Subject: [PATCH 22/28] Run child add-on reachability check on imported parent edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An imported listing created as a child that also joins a group could inherit a group-scoped opt-in add-on. If that add-on's would-be scope reached only the (suppressed) child and not the parent's booking page, the add-on became unbookable — the dead-end the interactive child-edge editor already rejects, but which the import skipped (it stopped at field compatibility). validateParentEdges now builds the would-be listing set (the new child appended at placeholder id 0 with its would-be group memberships) and, per named parent, runs the same reachability core the edge editor uses. To keep it under the request N+1 guard for a many-parent import, add childOnlyAddOnCheckerForListings, which resolves every add-on's scope once and returns a reusable per-parent checker. Covered by rejects/accepts tests in a new catalog-transfer-reachability.test.ts (its modifier setup is read-heavy, so it lives outside catalog-transfer.test.ts). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/import.ts | 35 +++++++++ src/shared/db/modifier-resolve.ts | 20 +++++ .../lib/catalog-transfer-reachability.test.ts | 75 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 test/lib/catalog-transfer-reachability.test.ts diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index ae91d7cee5..afe07b4e30 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -38,6 +38,11 @@ import { type ListingInput, listingsTable, } from "#shared/db/listings.ts"; +import { + childOnlyAddOnCheckerForListings, + type ListingGroupMembership, + toListingGroupMembership, +} from "#shared/db/modifier-resolve.ts"; import { isNameTakenAnywhere, loadCatalogNameIndex, @@ -262,6 +267,27 @@ const validateParentEdges = async ( .filter((g) => g.is_package && g.hide_package_listings) .map((g) => g.id), ); + // A child that joins a group can inherit a group-scoped opt-in add-on. If that + // add-on's would-be scope reaches only this (suppressed) child and not the + // parent's page, the add-on becomes unbookable — the same dead-end the edge + // editor rejects. Resolve every add-on's would-be scope once (the new child + // appended at placeholder id 0 with its would-be groups) and reuse it per + // parent. A child with no groups can't inherit such an add-on, so skip the work. + let addOnChecker: + | ((childId: number, pageIds: readonly number[]) => string | null) + | null = null; + if (groupIds.length > 0) { + const allMembership = await getGroupIdsByListingIds([...byId.keys()]); + const wouldBe: ListingGroupMembership[] = [ + ...[...byId.values()].map((l) => + toListingGroupMembership(l, allMembership), + ), + // active is irrelevant to child-reachability (only the deactivation check + // reads it); the placeholder child serves a page as far as this check cares. + { active: true, groupIds: [...groupIds], id: 0 }, + ]; + addOnChecker = await childOnlyAddOnCheckerForListings(wouldBe); + } for (const parentId of parentIds) { // parentIds were resolved by name from the same cached catalog byId reads, // so every id is present (trust the invariant rather than guard a dead path). @@ -283,6 +309,15 @@ const validateParentEdges = async ( } const error = edgeFieldError(listingToEdge(parent), childEdge); if (error) return error; + // The would-be child (id 0) must not carry an opt-in add-on reachable only + // through itself from this parent's page — mirroring the edge editor. + const addOn = addOnChecker?.(0, [parentId]); + if (addOn) { + return t("listings_table.children_err_child_addon", { + addon: addOn, + name: input.name, + }); + } } return null; }; diff --git a/src/shared/db/modifier-resolve.ts b/src/shared/db/modifier-resolve.ts index 39ccdc6249..15503536ce 100644 --- a/src/shared/db/modifier-resolve.ts +++ b/src/shared/db/modifier-resolve.ts @@ -597,6 +597,26 @@ export const childOnlyAddOnNameForListings = async ( parentPageListingIds, ); +/** + * Resolve every active opt-in add-on's would-be scope once against the supplied + * in-memory listing set, returning a reusable child-only-reachability checker. + * A caller validating many parent→child edges for one new child (a catalog + * import) resolves scopes a single time rather than once per parent, so it stays + * under the request N+1 read guard. The returned checker is + * {@link childOnlyAddOnNameForListings}'s pure core over the pre-resolved scopes. + */ +export const childOnlyAddOnCheckerForListings = async ( + allListings: ListingGroupMembership[], +): Promise< + (childId: number, parentPageListingIds: readonly number[]) => string | null +> => { + const scoped = await optionalAddOnsWithScopes( + inMemoryGroupScopeResolver(allListings), + ); + return (childId, parentPageListingIds) => + childOnlyAddOnNameWithScopes(scoped, childId, parentPageListingIds); +}; + /** The post-save shape of an opt-in add-on whose child-reachability must hold: * its trigger/active state and its **already-resolved** listing scope (null = * whole order; for a group scope, every listing in the linked groups). */ diff --git a/test/lib/catalog-transfer-reachability.test.ts b/test/lib/catalog-transfer-reachability.test.ts new file mode 100644 index 0000000000..9a1e022608 --- /dev/null +++ b/test/lib/catalog-transfer-reachability.test.ts @@ -0,0 +1,75 @@ +import { expect } from "@std/expect"; +import { it as test } from "@std/testing/bdd"; +import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { assignListingsToGroup } from "#shared/db/groups.ts"; +import { + createTestGroup, + createTestListing, + describeWithEnv, + insertModifier, + linkModifierGroup, + patchModifier, +} from "#test-utils"; + +// Child add-on reachability on import lives in its own file: the setup creates +// listings, a group, and an opt-in modifier, and folding it into +// catalog-transfer.test.ts would tip that file's per-request read count past +// the N+1 guard. + +/** Create an active, group-scoped opt-in add-on covering `groupId`. */ +const groupOptInAddOn = async ( + name: string, + groupId: number, +): Promise => { + const modifier = await insertModifier({ name }); + await patchModifier(modifier.id, { scope: "groups", trigger: "optional" }); + await linkModifierGroup(modifier.id, groupId); +}; + +describeWithEnv( + "catalog-transfer child add-on reachability", + { db: true }, + () => { + test("rejects a child import that orphans a group-scoped add-on", async () => { + // The add-on is scoped to a group whose only would-be member is the imported + // child. A child has no standalone page, and its parent isn't in the group, + // so the add-on would be reachable only through the suppressed child — the + // dead-end the edge editor rejects. + const group = await createTestGroup({ name: "Extra Group" }); + await createTestListing({ name: "Base Parent" }); + await groupOptInAddOn("Group Extra", group.id); + + const result = await importCatalog({ + groups: [{ group: "Extra Group" }], + kind: "listing", + listing: { maxAttendees: 1, name: "Orphan Kid" }, + parents: ["Base Parent"], + version: 1, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("unreachable"); + // The edge editor's own child-add-on message (its {addon}/{name} + // placeholders are quote-escaped in the locale, so assert its stable prose). + expect(result.error).toContain("opt-in add-on"); + expect(result.error).toContain("offering it as a child"); + }); + + test("accepts a child import when the parent's page also reaches the add-on", async () => { + // The parent is in the same group, so the group-scoped add-on reaches the + // parent's own booking page — not a dead end. + const group = await createTestGroup({ name: "Shared Group" }); + const parent = await createTestListing({ name: "Shared Parent" }); + await assignListingsToGroup([parent.id], group.id); + await groupOptInAddOn("Shared Extra", group.id); + + const result = await importCatalog({ + groups: [{ group: "Shared Group" }], + kind: "listing", + listing: { maxAttendees: 1, name: "Fine Kid" }, + parents: ["Shared Parent"], + version: 1, + }); + if (!result.ok) throw new Error(result.error); + }); + }, +); From 475e1c9bd7f55bea16e13fa4f0f117d3f95a8d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:09:35 +0000 Subject: [PATCH 23/28] Make export tolerant of unexportable rows; filter over-duration day prices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two linked findings on the export/import schema: - Export no longer 500s on a stored row the transfer schema can't represent. A listing created through the JSON API can hold values the API accepts but the transfer format rejects (e.g. bookable_days ["Funday"], an invalid fields string), and export ran them through a strict v.parse that threw. exportListing/exportGroup now return a CatalogExportError (via a safeParse helper) which the download route surfaces as an operator-facing 422, never a raw 500. - A listing's own dayPrices beyond its durationDays are now filtered on import, matching the form which only reads day_price_1..durationDays. A "5"-day price on a 2-day listing was persisted and would activate if the duration were later raised; it's dropped instead. (This is the listing's own field; package-member overrides for unoffered spans are still rejected, mirroring the package editor.) Covered by "returns 422 …" listing and group export tests and a "filters a listing's own day prices beyond its duration" import test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/export.ts | 59 ++++++++++++++----- src/features/admin/catalog-transfer/routes.ts | 21 +++++-- src/features/admin/catalog-transfer/schema.ts | 23 +++++++- test/lib/catalog-transfer-packages.test.ts | 21 +++++++ test/lib/catalog-transfer.test.ts | 21 +++++-- test/lib/server-catalog-transfer.test.ts | 29 +++++++++ 6 files changed, 148 insertions(+), 26 deletions(-) diff --git a/src/features/admin/catalog-transfer/export.ts b/src/features/admin/catalog-transfer/export.ts index 391dfcfc50..f71b0b7503 100644 --- a/src/features/admin/catalog-transfer/export.ts +++ b/src/features/admin/catalog-transfer/export.ts @@ -30,6 +30,7 @@ import type { AdminLevel } from "#shared/types.ts"; import { getListingGroupMemberships } from "./membership.ts"; import { CATALOG_TRANSFER_VERSION, + formatTransferIssues, GroupDataSchema, type GroupMember, type GroupTransfer, @@ -38,6 +39,26 @@ import { type ListingTransfer, } from "./schema.ts"; +/** Returned (not thrown) when a stored row holds a value the transfer format + * can't represent — e.g. a bookable-day name or contact field the admin JSON API + * accepted but the transfer schema rejects. The export route surfaces it as an + * operator-facing 4xx rather than letting a raw parse error become a 500. */ +export class CatalogExportError extends Error {} + +/** Project a stored row onto its transfer shape, or a {@link CatalogExportError} + * (with an intelligible per-field message) when the row can't be represented. */ +const parseExport = ( + schema: TSchema, + value: unknown, + what: string, +): v.InferOutput | CatalogExportError => { + const result = v.safeParse(schema, value); + if (result.success) return result.output; + return new CatalogExportError( + `This ${what} has a value that can't be exported — ${formatTransferIssues(result.issues)}`, + ); +}; + /** Listing columns that never travel: the id/slug/timestamp columns (an import * mints fresh ones) and the image/attachment columns (deliberately out of * scope). Named in snake_case for {@link listingsTable.rowToInput}. */ @@ -99,10 +120,22 @@ const overrideFields = ( export const exportListing = async ( id: number, adminLevel?: AdminLevel, -): Promise => { +): Promise => { const listing = await getStoredListingWithCount(id); if (!listing) return null; + const listingData = parseExport( + ListingDataSchema, + listingsTable.rowToInput( + listing, + adminLevel === "editor" + ? EDITOR_EXPORT_EXCLUDED + : LISTING_EXPORT_EXCLUDED, + ), + "listing", + ); + if (listingData instanceof CatalogExportError) return listingData; + const [memberships, groupNames, parentIds] = await Promise.all([ getListingGroupMemberships(id), getAllGroupNames(), @@ -131,15 +164,7 @@ export const exportListing = async ( return { groups, kind: "listing", - listing: v.parse( - ListingDataSchema, - listingsTable.rowToInput( - listing, - adminLevel === "editor" - ? EDITOR_EXPORT_EXCLUDED - : LISTING_EXPORT_EXCLUDED, - ), - ), + listing: listingData, parents, version: CATALOG_TRANSFER_VERSION, }; @@ -152,10 +177,17 @@ export const exportListing = async ( */ export const exportGroup = async ( id: number, -): Promise => { +): Promise => { const group = await groupsTable.findById(id); if (!group) return null; + const groupData = parseExport( + GroupDataSchema, + groupsTable.rowToInput(group, GROUP_EXPORT_EXCLUDED), + "group", + ); + if (groupData instanceof CatalogExportError) return groupData; + const rows = await getGroupPackagePrices(id); const [listingNames, dayPrices] = await Promise.all([ getListingNamesByIds(rows.map((r) => r.listing_id)), @@ -174,10 +206,7 @@ export const exportGroup = async ( })); return { - group: v.parse( - GroupDataSchema, - groupsTable.rowToInput(group, GROUP_EXPORT_EXCLUDED), - ), + group: groupData, kind: "group", members, version: CATALOG_TRANSFER_VERSION, diff --git a/src/features/admin/catalog-transfer/routes.ts b/src/features/admin/catalog-transfer/routes.ts index 7c502ee156..c0664aecc3 100644 --- a/src/features/admin/catalog-transfer/routes.ts +++ b/src/features/admin/catalog-transfer/routes.ts @@ -26,8 +26,9 @@ import { defineRoutes, type TypedRouteHandler } from "#routes/router.ts"; import { logActivity } from "#shared/db/activityLog.ts"; import { isDemoMode } from "#shared/demo.ts"; import { adminCatalogImportPage } from "#templates/admin/catalog-transfer.tsx"; -import { exportGroup, exportListing } from "./export.ts"; +import { CatalogExportError, exportGroup, exportListing } from "./export.ts"; import { importCatalog } from "./import.ts"; +import type { GroupTransfer, ListingTransfer } from "./schema.ts"; const IMPORT_PATH = "/admin/catalog/import"; @@ -56,12 +57,24 @@ const catalogFilename = (kind: string, name: string): string => { const downloadExport = ( request: Request, id: number, - load: (id: number, session: AuthSession) => Promise, + load: ( + id: number, + session: AuthSession, + ) => Promise, kind: string, nameOf: (blob: T) => string, ): Promise => requireContentOr(request, async (session) => { const blob = await load(id, session); + // A row created through the JSON API can hold a value the transfer format + // rejects (e.g. an unrecognised bookable day); surface that as an + // operator-facing 422 rather than a raw 500. + if (blob instanceof CatalogExportError) { + return new Response(blob.message, { + headers: { "content-type": "text/plain; charset=utf-8" }, + status: 422, + }); + } return blob ? jsonDownload(blob, catalogFilename(kind, nameOf(blob))) : notFoundResponse(); @@ -71,7 +84,7 @@ const downloadExport = ( const handleListingExport: TypedRouteHandler< "GET /admin/listing/:id/export.json" > = (request, { id }) => - downloadExport( + downloadExport( request, id, (listingId, session) => exportListing(listingId, session.adminLevel), @@ -83,7 +96,7 @@ const handleListingExport: TypedRouteHandler< const handleGroupExport: TypedRouteHandler< "GET /admin/groups/:id/export.json" > = (request, { id }) => - downloadExport( + downloadExport( request, id, (groupId) => exportGroup(groupId), diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index a7289fcde2..73986db716 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -163,7 +163,7 @@ const optPositiveInt = v.optional(PositiveIntSchema); * omitted rather than defaulted so the importing table applies its own column * defaults; `name` and `maxAttendees` are the only structural requirements. */ -export const ListingDataSchema = v.object({ +const ListingFieldsSchema = v.object({ active: optBoolean, assignBuiltSite: optBoolean, bookableDays: v.optional(v.array(BookableDaySchema)), @@ -196,6 +196,27 @@ export const ListingDataSchema = v.object({ usesLogistics: optBoolean, webhookUrl: optString, }); + +/** Drop day-price keys beyond the listing's own duration: the form only reads + * `day_price_1..durationDays`, so a stored/blob entry above that (e.g. duration 2 + * with a "5" price) is inert and must not silently activate if the duration is + * later raised. Filtered (not rejected) to mirror the form, which just ignores + * the extra inputs. */ +const filterDayPricesToDuration = ( + data: v.InferOutput, +): v.InferOutput => { + if (!data.dayPrices) return data; + const max = data.durationDays ?? 1; + const dayPrices = Object.fromEntries( + Object.entries(data.dayPrices).filter(([days]) => Number(days) <= max), + ); + return { ...data, dayPrices }; +}; + +export const ListingDataSchema = v.pipe( + ListingFieldsSchema, + v.transform(filterDayPricesToDuration), +); export type ListingData = v.InferOutput; /** The transferable columns of a group, keyed to match `GroupInput` (members diff --git a/test/lib/catalog-transfer-packages.test.ts b/test/lib/catalog-transfer-packages.test.ts index 66b950b569..a51e180311 100644 --- a/test/lib/catalog-transfer-packages.test.ts +++ b/test/lib/catalog-transfer-packages.test.ts @@ -1,6 +1,7 @@ import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { importCatalog } from "#routes/admin/catalog-transfer/import.ts"; +import { getListing } from "#shared/db/listings.ts"; import { createTestGroup, createTestListing, @@ -36,6 +37,26 @@ const importListingOverride = ( }); describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { + test("filters a listing's own day prices beyond its duration", async () => { + // The form only reads day_price_1..durationDays; a "5" price on a 2-day + // listing is inert and must be dropped on import, not persisted where a + // later duration bump would activate it. + const result = await importCatalog({ + kind: "listing", + listing: { + ...customisable, + dayPrices: { 1: 1000, 5: 5000 }, + maxAttendees: 1, + name: "Trimmed Days", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const stored = (await getListing(result.id))!; + expect(stored.day_prices[1]).toBe(1000); + expect(stored.day_prices[5]).toBeUndefined(); + }); + test("rejects a package member day-override for an unoffered span", async () => { // The member offers 1- and 2-day bookings; a 5-day override is a span it // doesn't have, so the package editor would never render that input. diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index 522c7381d3..d2a6eb0d74 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -1,6 +1,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { + CatalogExportError, exportGroup, exportListing, } from "#routes/admin/catalog-transfer/export.ts"; @@ -22,6 +23,14 @@ import { describeWithEnv, } from "#test-utils"; +/** Unwrap a successful export, failing if it returned null or an + * (unexpected-in-these-tests) {@link CatalogExportError}. */ +const unwrapExport = (blob: T | CatalogExportError | null): T => { + if (blob instanceof CatalogExportError) throw new Error(blob.message); + if (blob === null) throw new Error("export returned null"); + return blob; +}; + /** Import a listing that asks for a built site, returning the persisted * `assign_built_site` flag — true only where the builder is configured. */ const importBuiltSiteListing = async (name: string): Promise => { @@ -54,7 +63,7 @@ describeWithEnv("catalog-transfer", { db: true }, () => { }); await setChildIds(parent.id, [child.id]); - const blob = (await exportListing(child.id))!; + const blob = unwrapExport(await exportListing(child.id)); expect(blob.kind).toBe("listing"); expect(blob.parents).toEqual(["Parent Listing"]); expect(blob.groups).toEqual([{ group: "Regular Group" }]); @@ -125,7 +134,7 @@ describeWithEnv("catalog-transfer", { db: true }, () => { }, ]); - const blob = (await exportListing(member.id))!; + const blob = unwrapExport(await exportListing(member.id)); expect(blob.groups).toEqual([ { dayPrices: { "1": 900 }, @@ -168,7 +177,7 @@ describeWithEnv("catalog-transfer", { db: true }, () => { { listingId: c.id, price: 0, quantity: 1 }, ]); - const blob = (await exportGroup(group.id))!; + const blob = unwrapExport(await exportGroup(group.id)); expect(blob.group.isPackage).toBe(true); expect(blob.members).toEqual([ { listing: "Pkg A", packagePrice: 800, quantity: 2 }, @@ -208,7 +217,7 @@ describeWithEnv("catalog-transfer", { db: true }, () => { describe("import validation", () => { test("rejects a listing whose name already exists", async () => { const listing = await createTestListing({ name: "Existing" }); - const blob = (await exportListing(listing.id))!; + const blob = unwrapExport(await exportListing(listing.id)); const result = await importCatalog(blob); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); @@ -596,10 +605,10 @@ describeWithEnv("catalog-transfer review fixes", { db: true }, () => { webhookUrl: "https://example.com/hook", }); // The editor blob must not carry the PII sink URL the edit form hides… - const editorBlob = (await exportListing(listing.id, "editor"))!; + const editorBlob = unwrapExport(await exportListing(listing.id, "editor")); expect(editorBlob.listing.webhookUrl).toBeUndefined(); // …but staff still round-trip it. - const ownerBlob = (await exportListing(listing.id, "owner"))!; + const ownerBlob = unwrapExport(await exportListing(listing.id, "owner")); expect(ownerBlob.listing.webhookUrl).toBe("https://example.com/hook"); }); diff --git a/test/lib/server-catalog-transfer.test.ts b/test/lib/server-catalog-transfer.test.ts index b3d586426d..65d6683160 100644 --- a/test/lib/server-catalog-transfer.test.ts +++ b/test/lib/server-catalog-transfer.test.ts @@ -2,6 +2,7 @@ import { expect } from "@std/expect"; import { describe, it as test } from "@std/testing/bdd"; import { handleRequest } from "#routes"; import { signCsrfToken } from "#shared/csrf.ts"; +import { execute } from "#shared/db/client.ts"; import { getGroupIdsByListingId } from "#shared/db/groups.ts"; import { getAllListings } from "#shared/db/listings.ts"; import { @@ -81,6 +82,34 @@ describeWithEnv("server (catalog transfer)", { db: true }, () => { const response = await adminGet("/admin/listing/9999/export.json"); expect(response.status).toBe(404); }); + + test("returns 422 (not 500) when a row holds an unexportable value", async () => { + // The JSON API accepts bookable_days without validating the names, so a + // stored row can hold a value the transfer schema rejects. Exporting it + // must surface an operator-facing error, never a raw 500. + const listing = await createTestListing({ name: "Bad Days" }); + await execute("UPDATE listings SET bookable_days = ? WHERE id = ?", [ + JSON.stringify(["Funday"]), + listing.id, + ]); + const response = await adminGet( + `/admin/listing/${listing.id}/export.json`, + ); + expect(response.status).toBe(422); + expect(await response.text()).toContain("can't be exported"); + }); + + test("returns 422 when a group row holds an unexportable value", async () => { + const group = await createTestGroup({ name: "Bad Group" }); + // A negative capacity can't be represented by the transfer schema. + await execute("UPDATE groups SET max_attendees = ? WHERE id = ?", [ + -1, + group.id, + ]); + const response = await adminGet(`/admin/groups/${group.id}/export.json`); + expect(response.status).toBe(422); + expect(await response.text()).toContain("can't be exported"); + }); }); describe("import page", () => { From 823e698a132d7e9857f31516a0bcb4b281b8556e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:17:40 +0000 Subject: [PATCH 24/28] Cover the default-duration branch of the day-price filter The dayPrices filter's `durationDays ?? 1` right operand was never exercised (every test set durationDays), so deno flagged schema.ts:209. Add an import test with dayPrices and no durationDays. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- test/lib/catalog-transfer-packages.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/lib/catalog-transfer-packages.test.ts b/test/lib/catalog-transfer-packages.test.ts index a51e180311..ca3dfba110 100644 --- a/test/lib/catalog-transfer-packages.test.ts +++ b/test/lib/catalog-transfer-packages.test.ts @@ -57,6 +57,25 @@ describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { expect(stored.day_prices[5]).toBeUndefined(); }); + test("keeps a day price when no duration is given (defaults to 1)", async () => { + // No durationDays → the filter compares against the default of 1, so a + // 1-day price is retained. + const result = await importCatalog({ + kind: "listing", + listing: { + customisableDays: true, + dayPrices: { 1: 1000 }, + listingType: "daily", + maxAttendees: 1, + name: "Default Duration", + }, + version: 1, + }); + if (!result.ok) throw new Error(result.error); + const stored = (await getListing(result.id))!; + expect(stored.day_prices[1]).toBe(1000); + }); + test("rejects a package member day-override for an unoffered span", async () => { // The member offers 1- and 2-day bookings; a 5-day override is a span it // doesn't have, so the package editor would never render that input. From 159139ca6b5b0d7b99aca83c40776548b34352c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 09:21:31 +0000 Subject: [PATCH 25/28] Dedupe the day-price import tests via a shared helper The new default-duration test duplicated the import+unwrap+getListing block, tripping the test cpd gate. Extract importStoredListing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- test/lib/catalog-transfer-packages.test.ts | 43 ++++++++++------------ 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/test/lib/catalog-transfer-packages.test.ts b/test/lib/catalog-transfer-packages.test.ts index ca3dfba110..cb5876e929 100644 --- a/test/lib/catalog-transfer-packages.test.ts +++ b/test/lib/catalog-transfer-packages.test.ts @@ -36,23 +36,26 @@ const importListingOverride = ( version: 1, }); +/** Import a listing and return its stored row, throwing on any import error. */ +const importStoredListing = async (listing: Record) => { + const result = await importCatalog({ kind: "listing", listing, version: 1 }); + if (!result.ok) throw new Error(result.error); + const stored = await getListing(result.id); + if (!stored) throw new Error("listing not found after import"); + return stored; +}; + describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { test("filters a listing's own day prices beyond its duration", async () => { // The form only reads day_price_1..durationDays; a "5" price on a 2-day // listing is inert and must be dropped on import, not persisted where a // later duration bump would activate it. - const result = await importCatalog({ - kind: "listing", - listing: { - ...customisable, - dayPrices: { 1: 1000, 5: 5000 }, - maxAttendees: 1, - name: "Trimmed Days", - }, - version: 1, + const stored = await importStoredListing({ + ...customisable, + dayPrices: { 1: 1000, 5: 5000 }, + maxAttendees: 1, + name: "Trimmed Days", }); - if (!result.ok) throw new Error(result.error); - const stored = (await getListing(result.id))!; expect(stored.day_prices[1]).toBe(1000); expect(stored.day_prices[5]).toBeUndefined(); }); @@ -60,19 +63,13 @@ describeWithEnv("catalog-transfer package day overrides", { db: true }, () => { test("keeps a day price when no duration is given (defaults to 1)", async () => { // No durationDays → the filter compares against the default of 1, so a // 1-day price is retained. - const result = await importCatalog({ - kind: "listing", - listing: { - customisableDays: true, - dayPrices: { 1: 1000 }, - listingType: "daily", - maxAttendees: 1, - name: "Default Duration", - }, - version: 1, + const stored = await importStoredListing({ + customisableDays: true, + dayPrices: { 1: 1000 }, + listingType: "daily", + maxAttendees: 1, + name: "Default Duration", }); - if (!result.ok) throw new Error(result.error); - const stored = (await getListing(result.id))!; expect(stored.day_prices[1]).toBe(1000); }); From 6a5c6dd7617a581f136a13aaecd699e4894dd92c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:45:01 +0000 Subject: [PATCH 26/28] Carry bookable_alone through catalog transfers The transfer schema predated the bookable_alone column, so rowToInput supplied bookableAlone but ListingFieldsSchema never declared it and parseExport dropped it. A re-imported child then defaulted to false, losing its standalone /ticket page, catalog/API eligibility, and any add-on reachability that depended on the child's own page. Declare the field so it round-trips. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/schema.ts | 4 ++++ test/lib/catalog-transfer.test.ts | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/features/admin/catalog-transfer/schema.ts b/src/features/admin/catalog-transfer/schema.ts index 73986db716..d3709f5b72 100644 --- a/src/features/admin/catalog-transfer/schema.ts +++ b/src/features/admin/catalog-transfer/schema.ts @@ -166,6 +166,10 @@ const optPositiveInt = v.optional(PositiveIntSchema); const ListingFieldsSchema = v.object({ active: optBoolean, assignBuiltSite: optBoolean, + // A child listing that keeps its own standalone `/ticket` page. Carried so an + // export/import round-trip preserves it (otherwise a re-imported child defaults + // to `false`, losing its page, catalog/API eligibility, and add-on reach). + bookableAlone: optBoolean, bookableDays: v.optional(v.array(BookableDaySchema)), canPayMore: optBoolean, closesAt: v.optional(v.nullable(DatetimeSchema)), diff --git a/test/lib/catalog-transfer.test.ts b/test/lib/catalog-transfer.test.ts index 8eec14b518..4392861f21 100644 --- a/test/lib/catalog-transfer.test.ts +++ b/test/lib/catalog-transfer.test.ts @@ -89,6 +89,25 @@ describeWithEnv("catalog-transfer", { db: true }, () => { expect(await getParentIds(result.id)).toEqual([parent.id]); }); + test("preserves a child's bookable-alone standalone page", async () => { + const parent = await createTestListing({ name: "Alone Parent" }); + const child = await createTestListing({ + bookableAlone: true, + name: "Alone Child", + }); + await setChildIds(parent.id, [child.id]); + + const blob = unwrapExport(await exportListing(child.id)); + // The flag must survive the export (otherwise import defaults it to false). + expect(blob.listing.bookableAlone).toBe(true); + + blob.listing.name = "Alone Copy"; + const result = await importCatalog(blob); + if (!result.ok) throw new Error(result.error); + const imported = (await getListing(result.id))!; + expect(imported.bookable_alone).toBe(true); + }); + test("preserves closesAt and re-syncs the derived price rows", async () => { const result = await importCatalog({ kind: "listing", From ccba2f5662ea726b8f458c6dd695c2734908e1fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 10:56:04 +0000 Subject: [PATCH 27/28] Drop the now-dead group-siblings fallback in listing validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge unified getListingsByGroupIds to seed an entry for every id it is asked about, so siblingsByGroup.get(groupId) always resolves for the ids validateListingGroup iterates. The '?? []' fallback was therefore unreachable — a branch the 100% coverage gate could never cover. Assert the invariant instead so the branch is gone. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/shared/listings-actions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/listings-actions.ts b/src/shared/listings-actions.ts index fe89d6328e..537b0e55d0 100644 --- a/src/shared/listings-actions.ts +++ b/src/shared/listings-actions.ts @@ -121,7 +121,9 @@ const validateListingGroup: ListingUpdateCheck = async (input, existingId) => { if (!group) return "Selected group does not exist"; const typeError = groupListingTypeError( - siblingsByGroup.get(groupId) ?? [], + // getListingsByGroupIds seeds an entry (possibly empty) for every id it is + // asked about, and we iterate those same ids, so the lookup always resolves. + siblingsByGroup.get(groupId)!, // The DB column defaults to "standard" when omitted (e.g. a JSON API // create that sends group_ids but no listing_type), so validate against // that default rather than passing undefined and reading every standard From b9859424747dc09457fcdd0b22d9100b37fc6e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 11:10:50 +0000 Subject: [PATCH 28/28] Exempt bookable-alone children from the import add-on reachability check Now that imports carry bookableAlone, a child imported with bookable_alone=true kept its own /ticket page but was still run through the child-only add-on reachability guard, so a valid exported child in an add-on-scoped group could be rejected when its parent wasn't in scope. The edge editor exempts a bookable_alone child (its own page still offers the add-on); mirror that by skipping the guard here too. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX --- src/features/admin/catalog-transfer/import.ts | 5 ++++- test/lib/catalog-transfer-reachability.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/features/admin/catalog-transfer/import.ts b/src/features/admin/catalog-transfer/import.ts index be0cb225f0..67f203d8b4 100644 --- a/src/features/admin/catalog-transfer/import.ts +++ b/src/features/admin/catalog-transfer/import.ts @@ -276,10 +276,13 @@ const validateParentEdges = async ( // editor rejects. Resolve every add-on's would-be scope once (the new child // appended at placeholder id 0 with its would-be groups) and reuse it per // parent. A child with no groups can't inherit such an add-on, so skip the work. + // A `bookable_alone` child keeps its OWN booking page, so its add-on is still + // reachable there — the edge editor exempts it (`if (bookable_alone) continue`), + // so skip the check here too rather than reject a valid exported child. let addOnChecker: | ((childId: number, pageIds: readonly number[]) => string | null) | null = null; - if (groupIds.length > 0) { + if (groupIds.length > 0 && !input.bookableAlone) { const allMembership = await getGroupIdsByListingIds([...byId.keys()]); const wouldBe: ListingGroupMembership[] = [ ...[...byId.values()].map((l) => diff --git a/test/lib/catalog-transfer-reachability.test.ts b/test/lib/catalog-transfer-reachability.test.ts index 9a1e022608..91e89bf79e 100644 --- a/test/lib/catalog-transfer-reachability.test.ts +++ b/test/lib/catalog-transfer-reachability.test.ts @@ -54,6 +54,24 @@ describeWithEnv( expect(result.error).toContain("offering it as a child"); }); + test("accepts a bookable-alone child even when only it reaches the add-on", async () => { + // Same orphaning shape as above, but the child keeps its own /ticket page + // (bookable_alone), so the group-scoped add-on is still reachable there — + // the edge editor exempts a bookable_alone child, and so must the import. + const group = await createTestGroup({ name: "Alone Group" }); + await createTestListing({ name: "Alone Base Parent" }); + await groupOptInAddOn("Alone Extra", group.id); + + const result = await importCatalog({ + groups: [{ group: "Alone Group" }], + kind: "listing", + listing: { bookableAlone: true, maxAttendees: 1, name: "Alone Kid" }, + parents: ["Alone Base Parent"], + version: 1, + }); + if (!result.ok) throw new Error(result.error); + }); + test("accepts a child import when the parent's page also reaches the add-on", async () => { // The parent is in the same group, so the group-scoped add-on reaches the // parent's own booking page — not a dead end.