Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
2cd63c4
Enforce cross-entity name uniqueness for listings and groups
claude Jul 2, 2026
e461f3a
Add catalog transfer engine: export/import a listing or group as JSON
claude Jul 2, 2026
6fc6cbd
Wire catalog import/export into the admin UI and routes
claude Jul 2, 2026
40cb032
Mutation-harden the catalog-transfer modules
claude Jul 2, 2026
0e219bd
Cover the new export/import UI links and the entity-pages refactor
claude Jul 2, 2026
b9a8962
Move import-only membership writers out of shared db files
claude Jul 2, 2026
20881f1
Lock editor access to catalog import/export
claude Jul 2, 2026
7799f3c
Close pre-existing mutation gaps exposed by the name-uniqueness change
claude Jul 2, 2026
b1b1b37
Address Codex review and close catalog-transfer coverage gaps
claude Jul 2, 2026
ef82482
Fix CI: alias-rule violation and duplicate-name test fixtures
claude Jul 2, 2026
a04e8af
Harden catalog import/export per second Codex review
claude Jul 2, 2026
449223f
Batch parent-edge import; enforce name uniqueness in bulk group dupli…
claude Jul 2, 2026
434c693
Unify listing field-value validation across form, API, and import
claude Jul 2, 2026
e349b9b
Scope import field validation to the schema; fix datetime regex anchor
claude Jul 3, 2026
853da9e
Import: clear non-package overrides, reject hidden-package parents, d…
claude Jul 3, 2026
c7f359f
Batch group-compat validation so many-group imports stay under the N+…
claude Jul 3, 2026
c062e72
Fix many-group import test; reject bad datetime offsets; batch packag…
claude Jul 3, 2026
6c94583
Reject fractional datetimes without a seconds component on import
claude Jul 3, 2026
37e0c2a
Reject unsafe-integer prices in catalog imports
claude Jul 3, 2026
7e91714
Reject package day-price overrides for spans a member doesn't offer o…
claude Jul 3, 2026
d73f2de
Format catalog-transfer-packages test (biome)
claude Jul 3, 2026
a60d936
Run child add-on reachability check on imported parent edges
claude Jul 3, 2026
475e1c9
Make export tolerant of unexportable rows; filter over-duration day p…
claude Jul 3, 2026
823e698
Cover the default-duration branch of the day-price filter
claude Jul 3, 2026
159139c
Dedupe the day-price import tests via a shared helper
claude Jul 3, 2026
0d2c463
Merge remote-tracking branch 'origin/main' into claude/listing-group-…
claude Jul 3, 2026
6a5c6dd
Carry bookable_alone through catalog transfers
claude Jul 3, 2026
ccba2f5
Drop the now-dead group-siblings fallback in listing validation
claude Jul 3, 2026
b985942
Exempt bookable-alone children from the import add-on reachability check
claude Jul 3, 2026
6b91c00
Merge remote-tracking branch 'origin/main' into claude/listing-group-…
claude Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion scripts/mutation/equivalent-mutants.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +211,38 @@ 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

# 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

# attendee merge/refund route: mutants with no observable effect.
src/features/admin/attendees-merge.ts:356:10 === → != # toBookingChoice: keep_target and skip_source are consumed identically (attendee-merge.ts "do nothing for this booking"), so normalizing one to the other is unobservable
Expand Down
35 changes: 35 additions & 0 deletions src/features/admin/bulk-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,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,
Expand Down Expand Up @@ -141,6 +146,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<string | null> => {
const seen = new Set<string>();
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`;
Expand Down Expand Up @@ -175,6 +205,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]),
);
Expand Down
224 changes: 224 additions & 0 deletions src/features/admin/catalog-transfer/export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* 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,
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 type { AdminLevel } from "#shared/types.ts";
import { getListingGroupMemberships } from "./membership.ts";
import {
CATALOG_TRANSFER_VERSION,
formatTransferIssues,
GroupDataSchema,
type GroupMember,
type GroupTransfer,
ListingDataSchema,
type ListingMembership,
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 = <TSchema extends v.GenericSchema>(
schema: TSchema,
value: unknown,
what: string,
): v.InferOutput<TSchema> | 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}. */
const LISTING_EXPORT_EXCLUDED = [
"created",
"slug",
"slug_index",
"image_url",
"attachment_url",
"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;

/** 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<number, number> | undefined,
): Record<string, number> | undefined => {
if (!dayPrices || dayPrices.size === 0) return undefined;
const record: Record<string, number> = {};
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<number, number> | undefined,
): {
packagePrice?: number;
quantity?: number;
dayPrices?: Record<string, number>;
} => {
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,
adminLevel?: AdminLevel,
): Promise<ListingTransfer | CatalogExportError | null> => {
const listing = await getStoredListingWithCount(id);
if (!listing) return null;

// `day_prices` is a projected column (its source rows live in `listing_prices`
// now that the `listings.day_prices` column is retired), so `rowToInput` omits
// it — carry it explicitly when the listing actually has per-day prices.
const dayPrices =
Object.keys(listing.day_prices).length > 0
? { dayPrices: listing.day_prices }
: {};
const listingData = parseExport(
ListingDataSchema,
{
...listingsTable.rowToInput(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Materialize inherited defaults in listing exports

When a listing has use_defaults enabled, this exports the stored row values plus useDefaults: true, but the transfer file does not include the source site's listing defaults. If an operator exports a default-inheriting listing whose effective hidden, bookableDays, webhookUrl, or thankYouUrl now comes from defaults, importing it into another site with different or no defaults silently changes that listing's behavior; staff exports should either materialize the effective defaulted fields and clear useDefaults, or carry enough default data to preserve the source behavior.

Useful? React with 👍 / 👎.

listing,
adminLevel === "editor"
? EDITOR_EXPORT_EXCLUDED
: LISTING_EXPORT_EXCLUDED,
),
...dayPrices,
},
"listing",
);
if (listingData instanceof CatalogExportError) return listingData;

const [memberships, groupNames, parentIds] = await Promise.all([
getListingGroupMemberships(id),
getAllGroupNames(),
getParentIds(id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a parent listing's child edges in exports

When exporting a listing that is itself a parent, the blob only records the listings this row is offered under, not the required children it offers. Re-importing that parent therefore creates a standalone listing with no child selector, so buyers can book the parent alone and the add-on structure is lost; include child references in the transfer or reject/export-warn for parent listings.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by design. The transfer format records a listing's parents (what it's offered under), not the children it offers, because children are themselves separate listings — the format is single-entity, and every cross-reference (parents, group memberships, group members) is a by-name reference to an entity that already exists, never an embedded subtree. The add-on structure is preserved by exporting/importing each child, which references this parent by name on its own import (parents must already exist, which is exactly why the import resolves parent references rather than creating them). Exporting a lone parent yielding a standalone listing until its children are imported is the intended boundary, consistent with how memberships and parents are all handled.

Happy to revisit if you'd rather the format embed child references, but that's a format-scope change I'd want your call on rather than make unilaterally.


Generated by Claude Code

]);
const groupDayPrices = await getGroupDayPricesByGroupIds(
memberships.map((m) => m.group_id),
);

// 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) =>
parentNames.get(parentId),
)(parentIds);

return {
groups,
kind: "listing",
listing: listingData,
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<GroupTransfer | CatalogExportError | null> => {
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)),
getGroupDayPrices(id),
]);

// 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: groupData,
kind: "group",
members,
version: CATALOG_TRANSFER_VERSION,
};
};
Loading