-
-
Notifications
You must be signed in to change notification settings - Fork 3
Import/export a listing or group as JSON, with cross-entity name uniqueness #1511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2cd63c4
e461f3a
6fc6cbd
40cb032
0e219bd
b9a8962
20881f1
7799f3c
b1b1b37
ef82482
a04e8af
449223f
434c693
e349b9b
853da9e
c7f359f
c062e72
6c94583
37e0c2a
7e91714
d73f2de
a60d936
475e1c9
823e698
159139c
0d2c463
6a5c6dd
ccba2f5
b985942
6b91c00
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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( | ||
| 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), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }; | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a listing has
use_defaultsenabled, this exports the stored row values plususeDefaults: true, but the transfer file does not include the source site's listing defaults. If an operator exports a default-inheriting listing whose effectivehidden,bookableDays,webhookUrl, orthankYouUrlnow 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 clearuseDefaults, or carry enough default data to preserve the source behavior.Useful? React with 👍 / 👎.