Skip to content

Import/export a listing or group as JSON, with cross-entity name uniqueness - #1511

Merged
stefan-burke merged 30 commits into
mainfrom
claude/listing-group-import-export-l0d4b8
Jul 3, 2026
Merged

stefan-burke merged 30 commits into
mainfrom
claude/listing-group-import-export-l0d4b8

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 2, 2026

Copy link
Copy Markdown
Member

What

Adds the ability to export a single listing or group to a JSON blob and import it back — into this install or another — capturing all of the entity's facets, and enforces name uniqueness so those blobs can reference the catalog by name.

Name uniqueness (new invariant)

A listing or group display name is now unique across both tables: a listing may not share a name with another listing or with a group, and vice versa. Enforced on add and edit, for HTML forms and the admin JSON API, for both entity kinds.

  • New src/shared/db/name-registry.ts: a cached, decrypt-in-memory name index (no schema column needed) — isNameTakenAnywhere plus name→id resolution with missing/ambiguous outcomes.
  • Wired into validateListingInput and validateGroupWithPackage (the shared validators both paths funnel through); the group create resource gains the missing validate.
  • New error.name_in_use message.

Catalog transfer (src/features/admin/catalog-transfer/)

A versioned, id-free JSON format — every cross-reference (a listing's parents and group memberships, a group's member listings) is by name.

  • schema.ts — a valibot discriminated union (kind: "listing" | "group") that is the single source of truth: validates an uploaded blob at the boundary with per-field messages, and types the exporter's output. Day-count keys and capacities are range-checked so a typo or zero-capacity blob is a field error, not a silent drop.
  • export.ts — builds the blob from the decrypted stored row and its facets: prices (unit_price/day_prices), group memberships with package price/quantity/per-day overrides, and parent references. Images/attachments, ledger connections, and attendees are deliberately excluded.
  • import.ts — parse → check name uniqueness → resolve parents/groups/members by name (intelligible errors on a missing/ambiguous/duplicate name) → reuse the existing listing/group validators (type compatibility, package rules, single-level parent-edge compatibility) → write in one transaction. Role-aware policy is re-applied so an editor import can't set a webhook URL / use_defaults and a built-site assignment is cleared where the builder is disabled. Never throws for bad input; every failure returns an operator-facing message.
  • membership.ts — the import membership writers, batched into at most two multi-row INSERTs so a large group stays under the interactive-transaction round-trip cap.

Admin UI & routes (content-gated — owner/manager/editor)

  • GET /admin/listing/:id/export.json and /admin/groups/:id/export.json download the entity as a JSON attachment.
  • GET/POST /admin/catalog/import — an upload form that applies a blob and redirects with a success or error flash. Editors can reach the import page and the export routes.
  • Export links on the listing/group detail pages; an "Import from file" button on the listings and groups list pages; a new catalog-transfer i18n namespace.

Notes

  • No DB migration: name lookups reuse the already-cached, decrypted catalog rather than adding a blind-index column.
  • The import writers live in the transfer feature (membership.ts) rather than in #shared/db/groups.ts/listing-prices.ts, keeping those heavily-shared modules out of the change set.
  • A few existing test fixtures that created two same-named listings were updated to use distinct names, now that uniqueness is enforced.

Testing

  • Round-trip and every validation-failure path for both entity kinds (schema/export/import/routes), name-registry and validator unit tests, editor-access tests, and template-rendering assertions for the new links. The new modules are at 100% line and branch coverage.
  • CI (test.yml: lint:ci, typecheck, cpd, build:edge, and the full suite under the 100%-coverage gate) passes on the head commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

claude added 8 commits July 2, 2026 19:16
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7799f3ce42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/routes.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/membership.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
claude added 2 commits July 2, 2026 21:53
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
@stefan-burke
stefan-burke added this pull request to the merge queue Jul 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 2, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ef824820df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/routes.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts Outdated
Comment thread src/shared/listings-actions.ts
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a04e8affb8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/import.ts Outdated
…cation

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 449223fa97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/routes.ts
Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
): Promise<boolean> => {
const key = normalizeEntityName(name);
if (key === "") return false;
const { group, listing } = await loadCatalogNameIndex();

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 Make catalog name uniqueness atomic

When two listing/group creates or imports with the same normalized name overlap, both can pass this decrypted cache scan before either insert commits; there is no DB-level unique key on the encrypted names and the write transaction does not re-check the namespace. That leaves duplicate names created by new writes, making later catalog imports ambiguous despite the new invariant; enforce this with a normalized blind index/unique constraint or re-check under the serialized write path before inserting.

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.

Deferring to a dedicated follow-up. This is a pre-existing property of the name registry, not introduced by this PR: the create/import paths scan the decrypted name cache and there's no DB-level unique key on the encrypted names. Closing the TOCTOU window properly needs a normalized blind-index UNIQUE constraint — a schema migration plus a backfill of every existing listing/group name plus a re-check under the serialized write across all create paths — which is materially broader than this import/export change. Tracking it separately rather than bolting a partial guard on here.


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 434c693b03

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts
claude added 3 commits July 3, 2026 00:01
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
…isable in demo

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7f359f8b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
Comment thread src/features/admin/catalog-transfer/import.ts
Comment thread src/features/admin/catalog-transfer/import.ts
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

Comment thread src/features/admin/catalog-transfer/import.ts
…e-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c062e72f8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts Outdated
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c945835cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts
claude added 2 commits July 3, 2026 01:55
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
…n import

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
claude added 2 commits July 3, 2026 08:12
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a60d936bc4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts
Comment thread src/features/admin/catalog-transfer/export.ts Outdated
claude added 4 commits July 3, 2026 09:09
…rices

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
…import-export-l0d4b8

# Conflicts:
#	scripts/mutation/equivalent-mutants.txt
#	src/shared/db/groups.ts
@stefan-burke
stefan-burke enabled auto-merge July 3, 2026 10:21

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0d2c463907

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/schema.ts
claude added 2 commits July 3, 2026 10:45
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ccba2f5662

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/catalog-transfer/import.ts
claude added 2 commits July 3, 2026 11:10
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX
…import-export-l0d4b8

# Conflicts:
#	src/ui/templates/admin/listings.tsx
@stefan-burke
stefan-burke added this pull request to the merge queue Jul 3, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6b91c00349

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
// A JSON export download (see catalog-transfer). Content-gated like the tab,
// and a read, so — unlike duplicate — it stays available in read-only mode.
href: (entity) => actionUrl(entity, "export.json"),

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 Put export links on editor-visible pages

For editor sessions, this export action never renders because LISTING_ACTIONS is only displayed inside the Actions tab, and that tab is visible: staffOnly; the only group export link has the same problem on the staff-only group detail page. Since the new export routes themselves are content-gated and the import buttons are visible to editors, editors can import catalog files but cannot discover/export one without hand-crafting the URL, so the export links need to live on an editor-visible edit/list surface or the tab/page gate needs to change.

Useful? React with 👍 / 👎.

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 👍 / 👎.

Comment on lines +268 to +272
export const ListingTransferSchema = v.object({
groups: v.optional(v.array(ListingMembershipSchema), []),
kind: v.literal("listing"),
listing: ListingDataSchema,
parents: v.optional(v.array(NameRefSchema), []),

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 Reject unknown relationship keys in transfer files

With these relationship arrays optional and defaulting to [], a same-version file that misspells a key such as parent/group instead of parents/groups is accepted as if it intentionally had no parent edges or group memberships, creating a standalone listing and silently dropping structure. Because the format is versioned and exact-version gated, unknown keys in the transfer objects should be rejected (for example with strict object schemas) rather than treated as omitted optional fields.

Useful? React with 👍 / 👎.

@stefan-burke
stefan-burke added this pull request to the merge queue Jul 3, 2026
Merged via the queue into main with commit 6fc04a1 Jul 3, 2026
2 checks passed
@stefan-burke
stefan-burke deleted the claude/listing-group-import-export-l0d4b8 branch July 3, 2026 11:59
BlueHairMinerBoy pushed a commit to BlueHairMinerBoy/tickets that referenced this pull request Jul 4, 2026
…ity (chobbledotcom#1516)

Follow-up to the three Codex P2 findings on chobbledotcom#1511:

- schema.ts: the transfer schemas are now strictObject, so an unknown or
  misspelled key (e.g. "parent" for "parents") in a versioned,
  exact-version-gated file is a field error rather than being silently
  dropped — which would otherwise import a listing with no parents/groups
  or a missing column.
- Export links now also render on the editor-visible edit surfaces (the
  listing Edit tab and the group edit form), not only the staff-only
  Actions tab / group detail page. Editors, who can already import, can
  now discover and export via the UI (the export routes are content-gated).
- Lock in that export/import mirror raw database cells, never the
  read-time default overlay: a use_defaults listing round-trips its own
  stored cells (imported even when they conflict with a site default) and
  the export reflects those cells, not the resolved values. No source
  change — a test guards the intended behaviour.


Claude-Session: https://claude.ai/code/session_013zHSr7TjvPW4WVciygozHX

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants