@aglyn/plugins-commerce 1.0.0-beta.291 → 1.0.0-beta.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +1 -0
  2. package/package.json +13 -13
  3. package/src/lib/components/cart-fulfillment.d.ts +3 -0
  4. package/src/lib/components/cart-fulfillment.js.map +1 -1
  5. package/src/lib/components/cart.js +14 -4
  6. package/src/lib/components/cart.js.map +1 -1
  7. package/src/lib/components/console/host-quotes-card.component.js +11 -9
  8. package/src/lib/components/console/host-quotes-card.component.js.map +1 -1
  9. package/src/lib/components/console/invoice-send.component.js +1 -1
  10. package/src/lib/components/console/invoice-send.component.js.map +1 -1
  11. package/src/lib/components/console/pos/pos-item-dialog.component.js +3 -2
  12. package/src/lib/components/console/pos/pos-item-dialog.component.js.map +1 -1
  13. package/src/lib/components/console/pos-kiosk-page.component.js +2 -1
  14. package/src/lib/components/console/pos-kiosk-page.component.js.map +1 -1
  15. package/src/lib/components/console/pos-page.component.js +1 -1
  16. package/src/lib/components/console/pos-page.component.js.map +1 -1
  17. package/src/lib/components/console/product-add-ons-fields.component.d.ts +41 -0
  18. package/src/lib/components/console/product-add-ons-fields.component.js +356 -0
  19. package/src/lib/components/console/product-add-ons-fields.component.js.map +1 -0
  20. package/src/lib/components/console/product-editor-dialog.component.js +31 -5
  21. package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
  22. package/src/lib/components/console/product-register-fields.component.d.ts +4 -5
  23. package/src/lib/components/console/product-register-fields.component.js +8 -197
  24. package/src/lib/components/console/product-register-fields.component.js.map +1 -1
  25. package/src/lib/components/console/products-hub-zone.component.js +6 -0
  26. package/src/lib/components/console/products-hub-zone.component.js.map +1 -1
  27. package/src/lib/components/console/quote-pay-link.component.d.ts +8 -1
  28. package/src/lib/components/console/quote-pay-link.component.js +29 -3
  29. package/src/lib/components/console/quote-pay-link.component.js.map +1 -1
  30. package/src/lib/components/console/quote-send.component.js +1 -4
  31. package/src/lib/components/console/quote-send.component.js.map +1 -1
  32. package/src/lib/components/product-add-ons.d.ts +28 -0
  33. package/src/lib/components/product-add-ons.js +136 -0
  34. package/src/lib/components/product-add-ons.js.map +1 -0
  35. package/src/lib/components/product-detail.d.ts +16 -0
  36. package/src/lib/components/product-detail.js +112 -19
  37. package/src/lib/components/product-detail.js.map +1 -1
  38. package/src/lib/components/product-grid.d.ts +8 -2
  39. package/src/lib/components/product-grid.js +6 -1
  40. package/src/lib/components/product-grid.js.map +1 -1
  41. package/src/lib/declarations.console-server.js +15 -0
  42. package/src/lib/declarations.console-server.js.map +1 -1
  43. package/src/lib/model/commerce-cart.d.ts +15 -3
  44. package/src/lib/model/commerce-cart.js +10 -3
  45. package/src/lib/model/commerce-cart.js.map +1 -1
  46. package/src/lib/model/commerce-invoices.d.ts +7 -0
  47. package/src/lib/model/commerce-invoices.js +11 -0
  48. package/src/lib/model/commerce-invoices.js.map +1 -1
  49. package/src/lib/model/commerce-orders.d.ts +6 -3
  50. package/src/lib/model/commerce-orders.js +3 -0
  51. package/src/lib/model/commerce-orders.js.map +1 -1
  52. package/src/lib/model/commerce-pos-kiosk.d.ts +1 -1
  53. package/src/lib/model/commerce-pos-kiosk.js +3 -2
  54. package/src/lib/model/commerce-pos-kiosk.js.map +1 -1
  55. package/src/lib/model/commerce-quote-email.d.ts +14 -0
  56. package/src/lib/model/commerce-quote-email.js +25 -9
  57. package/src/lib/model/commerce-quote-email.js.map +1 -1
  58. package/src/lib/model/commerce-quote-proofs.d.ts +7 -0
  59. package/src/lib/model/commerce-quote-proofs.js +15 -0
  60. package/src/lib/model/commerce-quote-proofs.js.map +1 -1
  61. package/src/lib/model/commerce-quotes.js +1 -1
  62. package/src/lib/model/commerce-quotes.js.map +1 -1
  63. package/src/lib/model/commerce.d.ts +18 -0
  64. package/src/lib/model/commerce.js +26 -0
  65. package/src/lib/model/commerce.js.map +1 -1
  66. package/src/lib/model/product-modifiers.d.ts +74 -4
  67. package/src/lib/model/product-modifiers.js +131 -10
  68. package/src/lib/model/product-modifiers.js.map +1 -1
  69. package/src/lib/server/billing-webhook.js +64 -43
  70. package/src/lib/server/billing-webhook.js.map +1 -1
  71. package/src/lib/server/cart-checkout.js +14 -2
  72. package/src/lib/server/cart-checkout.js.map +1 -1
  73. package/src/lib/server/cart.d.ts +5 -0
  74. package/src/lib/server/cart.js +41 -4
  75. package/src/lib/server/cart.js.map +1 -1
  76. package/src/lib/server/catalog.d.ts +6 -0
  77. package/src/lib/server/catalog.js +2 -0
  78. package/src/lib/server/catalog.js.map +1 -1
  79. package/src/lib/server/checkout-extras.js +3 -1
  80. package/src/lib/server/checkout-extras.js.map +1 -1
  81. package/src/lib/server/checkout.js +28 -3
  82. package/src/lib/server/checkout.js.map +1 -1
  83. package/src/lib/server/local-fulfillment.js +5 -1
  84. package/src/lib/server/local-fulfillment.js.map +1 -1
  85. package/src/lib/server/pos-offline-sync.js +1 -1
  86. package/src/lib/server/pos-offline-sync.js.map +1 -1
  87. package/src/lib/server/pos-order.js +1 -1
  88. package/src/lib/server/pos-order.js.map +1 -1
  89. package/src/lib/server/product.d.ts +26 -0
  90. package/src/lib/server/product.js +18 -0
  91. package/src/lib/server/product.js.map +1 -1
  92. package/src/lib/server/products-write.js +5 -2
  93. package/src/lib/server/products-write.js.map +1 -1
  94. package/src/lib/server/quote-email.js.map +1 -1
  95. package/src/lib/server/quote-link-token.d.ts +4 -2
  96. package/src/lib/server/quote-link-token.js +4 -2
  97. package/src/lib/server/quote-link-token.js.map +1 -1
  98. package/src/lib/server/quote-pay.d.ts +5 -1
  99. package/src/lib/server/quote-pay.js +5 -2
  100. package/src/lib/server/quote-pay.js.map +1 -1
  101. package/src/lib/server/quote-proofs.d.ts +9 -1
  102. package/src/lib/server/quote-proofs.js +92 -23
  103. package/src/lib/server/quote-proofs.js.map +1 -1
  104. package/src/lib/server/quote-upload-keys.d.ts +50 -0
  105. package/src/lib/server/quote-upload-keys.js +86 -0
  106. package/src/lib/server/quote-upload-keys.js.map +1 -0
  107. package/src/lib/server/quote-upload-sweep.d.ts +30 -0
  108. package/src/lib/server/quote-upload-sweep.js +117 -0
  109. package/src/lib/server/quote-upload-sweep.js.map +1 -0
  110. package/src/lib/utils/add-to-cart.d.ts +8 -0
  111. package/src/lib/utils/add-to-cart.js +10 -2
  112. package/src/lib/utils/add-to-cart.js.map +1 -1
  113. package/src/lib/utils/quote-email-content.d.ts +1 -0
  114. package/src/lib/utils/quote-email-content.js +7 -4
  115. package/src/lib/utils/quote-email-content.js.map +1 -1
@@ -17,6 +17,21 @@
17
17
  import * as CommerceModel from "../model/index.js";
18
18
  import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
19
  import { readStoreDay } from "./store-day.js";
20
+ /** The website add-on groups of a product, in the page's shape (AGL-4100). */ export function publicModifierGroups(product) {
21
+ return CommerceModel.websiteModifierGroups(product).map((group)=>({
22
+ id: group.id,
23
+ name: group.name,
24
+ min: group.min,
25
+ max: group.max,
26
+ options: group.options.map((option)=>_extends({
27
+ id: option.id,
28
+ name: option.name,
29
+ priceCents: option.priceCents
30
+ }, option.priceCentsByVariant && Object.keys(option.priceCentsByVariant).length ? {
31
+ priceCentsByVariant: _extends({}, option.priceCentsByVariant)
32
+ } : {}))
33
+ }));
34
+ }
20
35
  /**
21
36
  * Map a lifted product onto the public PDP DTO.
22
37
  *
@@ -32,6 +47,7 @@ import { readStoreDay } from "./store-day.js";
32
47
  var _product_mediaUrls, _product_options;
33
48
  var _product_tags;
34
49
  const asksUseDate = productAsksUseDateAnywhere(product);
50
+ const modifierGroups = publicModifierGroups(product);
35
51
  return _extends({
36
52
  id,
37
53
  name: product.name,
@@ -69,6 +85,8 @@ import { readStoreDay } from "./store-day.js";
69
85
  subscription: product.subscription
70
86
  } : {}, product.subscription && product.subscriptionOptional ? {
71
87
  subscriptionOptional: true
88
+ } : {}, modifierGroups.length ? {
89
+ modifierGroups
72
90
  } : {});
73
91
  }
74
92
  /** Whether any variant of a product asks the buyer for a use day (AGL-4043). */ export function productAsksUseDateAnywhere(product) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { readStoreDay } from './store-day'\n\nexport interface PublicProductDetail {\n id: string\n name: string\n slug: string\n description?: string\n type: CommerceModel.ProductType\n mediaUrls: string[]\n options: CommerceModel.ProductOption[]\n variants: Array<{\n id: string\n options?: Record<string, string>\n /**\n * `null` for a variant listed before it has a price (AGL-3676): the page\n * says \"Price coming soon\" and offers nothing to buy, as no sale door\n * would sell it.\n */\n priceUsd: number | null\n compareAtPriceUsd?: number\n soldOut: boolean\n imageUrl?: string\n /**\n * Merchant's stock-keeping unit (AGL-686). Carried into the public DTO\n * so the server can put `sku` on the Product structured data — Google\n * Merchant treats it as required for a product offer, and without it a\n * listing is simply ineligible for rich results rather than warned about.\n * It is already public information: it prints on receipts and invoices.\n */\n sku?: string\n /**\n * The buyer may choose the day they will use it (a pass bought ahead,\n * AGL-4043): the page offers an optional date. Absent when it does not.\n */\n asksUseDate?: true\n }>\n /**\n * The store's time zone, sent only when a variant asks for a use day, so\n * the page offers no day that has passed on the store's calendar.\n */\n useDayTimeZone?: string\n tags?: string[]\n /** Recurring billing (AGL-303) so the PDP frames the price. */\n subscription?: { interval: 'month' | 'year'; trialDays?: number }\n /** Buyer may choose one-time OR subscribe (AGL-545). */\n subscriptionOptional?: boolean\n}\n\n/**\n * Map a lifted product onto the public PDP DTO.\n *\n * Extracted (AGL-659) so the site-page resolver can put the SAME payload\n * into page props as the API returns — the PDP block used to fetch this in\n * an effect, which meant the server rendered a skeleton and crawlers saw a\n * product page with no product in it. One definition keeps the server-\n * rendered and client-fetched shapes from drifting.\n *\n * Display-only by design: stock is a `soldOut` boolean rather than a raw\n * count, and the charge still prices server-side.\n */\nexport function toPublicProductDetail(\n id: string,\n product: ReturnType<typeof CommerceModel.liftLegacyProduct>,\n options: { timeZone?: string | null } = {},\n): PublicProductDetail {\n const asksUseDate = productAsksUseDateAnywhere(product)\n return {\n id,\n name: product.name,\n slug: product.slug,\n ...(product.description ? { description: product.description } : {}),\n type: product.type,\n mediaUrls:\n product.mediaUrls ?? (product.imageUrl ? [product.imageUrl] : []),\n options: product.options ?? [],\n variants: product.variants.map((variant) => ({\n id: variant.id,\n ...(variant.options ? { options: variant.options } : {}),\n priceUsd: CommerceModel.variantHasPrice(variant) ? variant.priceUsd : null,\n ...(variant.compareAtPriceUsd\n ? { compareAtPriceUsd: variant.compareAtPriceUsd }\n : {}),\n soldOut:\n variant.inventory != null &&\n Number(variant.inventory) <= 0 &&\n product.oversellPolicy !== 'backorder',\n ...(variant.imageUrl ? { imageUrl: variant.imageUrl } : {}),\n ...(variant.sku ? { sku: variant.sku } : {}),\n ...(CommerceModel.productAsksUseDate(product, variant.id) ? { asksUseDate: true as const } : {}),\n })),\n ...(asksUseDate ? { useDayTimeZone: options.timeZone || 'UTC' } : {}),\n ...(product.tags?.length ? { tags: product.tags } : {}),\n ...(product.subscription ? { subscription: product.subscription } : {}),\n ...(product.subscription && product.subscriptionOptional\n ? { subscriptionOptional: true }\n : {}),\n }\n}\n\n/** Whether any variant of a product asks the buyer for a use day (AGL-4043). */\nexport function productAsksUseDateAnywhere(\n product: Pick<ReturnType<typeof CommerceModel.liftLegacyProduct>, 'variants' | 'checkIn'>,\n): boolean {\n return product.variants.some((variant) => CommerceModel.productAsksUseDate(product, variant.id))\n}\n\n/**\n * Public product detail by slug (AGL-292): everything the PDP block\n * needs — variants with per-option prices and stock booleans, never raw\n * counts. Display-only; the charge still prices server-side.\n */\nexport const productHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const slug = String(req.query.slug ?? '')\n if (!hostId || !slug) {\n return res.status(400).json({ error: 'Missing hostId or slug' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .where('slug', '==', slug)\n .limit(1)\n .get()\n const docSnapshot = snapshot.docs[0]\n const raw = docSnapshot?.data() as any\n // A product sold in person only (AGL-4060) has no page online: the same\n // 404 as a draft, so its address says nothing about it.\n if (!CommerceModel.productListedOnline(raw)) {\n return res.status(404).json({ error: 'Unknown product' })\n }\n const product = CommerceModel.liftLegacyProduct(raw)\n // The store's calendar, only for a product that asks for a use day.\n const timeZone = productAsksUseDateAnywhere(product)\n ? (await readStoreDay(firestore.collection('hosts').doc(hostId))).timeZone\n : null\n const detail = toPublicProductDetail(docSnapshot.id, product, { timeZone })\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=300',\n )\n return res.status(200).json({ product: detail })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Product unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","readStoreDay","toPublicProductDetail","id","product","options","asksUseDate","productAsksUseDateAnywhere","name","slug","description","type","mediaUrls","imageUrl","variants","map","variant","priceUsd","variantHasPrice","compareAtPriceUsd","soldOut","inventory","Number","oversellPolicy","sku","productAsksUseDate","useDayTimeZone","timeZone","tags","length","subscription","subscriptionOptional","some","productHandler","req","res","hostId","String","query","status","json","error","firestore","app","snapshot","collection","doc","where","limit","get","docSnapshot","docs","raw","data","productListedOnline","liftLegacyProduct","detail","setHeader","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/product.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { readStoreDay } from './store-day'\n\nexport interface PublicProductDetail {\n id: string\n name: string\n slug: string\n description?: string\n type: CommerceModel.ProductType\n mediaUrls: string[]\n options: CommerceModel.ProductOption[]\n variants: Array<{\n id: string\n options?: Record<string, string>\n /**\n * `null` for a variant listed before it has a price (AGL-3676): the page\n * says \"Price coming soon\" and offers nothing to buy, as no sale door\n * would sell it.\n */\n priceUsd: number | null\n compareAtPriceUsd?: number\n soldOut: boolean\n imageUrl?: string\n /**\n * Merchant's stock-keeping unit (AGL-686). Carried into the public DTO\n * so the server can put `sku` on the Product structured data — Google\n * Merchant treats it as required for a product offer, and without it a\n * listing is simply ineligible for rich results rather than warned about.\n * It is already public information: it prints on receipts and invoices.\n */\n sku?: string\n /**\n * The buyer may choose the day they will use it (a pass bought ahead,\n * AGL-4043): the page offers an optional date. Absent when it does not.\n */\n asksUseDate?: true\n }>\n /**\n * The store's time zone, sent only when a variant asks for a use day, so\n * the page offers no day that has passed on the store's calendar.\n */\n useDayTimeZone?: string\n tags?: string[]\n /** Recurring billing (AGL-303) so the PDP frames the price. */\n subscription?: { interval: 'month' | 'year'; trialDays?: number }\n /** Buyer may choose one-time OR subscribe (AGL-545). */\n subscriptionOptional?: boolean\n /**\n * The add-ons the page offers (AGL-4100): ONLY the groups switched to\n * \"Show on the website\". A register-only group never leaves the server, so\n * a shop's in-person choices and prices are not published. Absent: none.\n * Display-only like the prices above; every sale door prices them again\n * from the product.\n */\n modifierGroups?: PublicModifierGroup[]\n}\n\n/** An add-on group as the product page shows it (AGL-4100). */\nexport interface PublicModifierGroup {\n id: string\n name: string\n min: number\n max: number\n options: Array<{\n id: string\n name: string\n priceCents: number\n /** What the choice adds on a variant priced differently, by variant id. */\n priceCentsByVariant?: Record<string, number>\n }>\n}\n\n/** The website add-on groups of a product, in the page's shape (AGL-4100). */\nexport function publicModifierGroups(\n product: { modifierGroups?: unknown },\n): PublicModifierGroup[] {\n return CommerceModel.websiteModifierGroups(product).map((group) => ({\n id: group.id,\n name: group.name,\n min: group.min,\n max: group.max,\n options: group.options.map((option) => ({\n id: option.id,\n name: option.name,\n priceCents: option.priceCents,\n ...(option.priceCentsByVariant && Object.keys(option.priceCentsByVariant).length\n ? { priceCentsByVariant: { ...option.priceCentsByVariant } }\n : {}),\n })),\n }))\n}\n\n/**\n * Map a lifted product onto the public PDP DTO.\n *\n * Extracted (AGL-659) so the site-page resolver can put the SAME payload\n * into page props as the API returns — the PDP block used to fetch this in\n * an effect, which meant the server rendered a skeleton and crawlers saw a\n * product page with no product in it. One definition keeps the server-\n * rendered and client-fetched shapes from drifting.\n *\n * Display-only by design: stock is a `soldOut` boolean rather than a raw\n * count, and the charge still prices server-side.\n */\nexport function toPublicProductDetail(\n id: string,\n product: ReturnType<typeof CommerceModel.liftLegacyProduct>,\n options: { timeZone?: string | null } = {},\n): PublicProductDetail {\n const asksUseDate = productAsksUseDateAnywhere(product)\n const modifierGroups = publicModifierGroups(product)\n return {\n id,\n name: product.name,\n slug: product.slug,\n ...(product.description ? { description: product.description } : {}),\n type: product.type,\n mediaUrls:\n product.mediaUrls ?? (product.imageUrl ? [product.imageUrl] : []),\n options: product.options ?? [],\n variants: product.variants.map((variant) => ({\n id: variant.id,\n ...(variant.options ? { options: variant.options } : {}),\n priceUsd: CommerceModel.variantHasPrice(variant) ? variant.priceUsd : null,\n ...(variant.compareAtPriceUsd\n ? { compareAtPriceUsd: variant.compareAtPriceUsd }\n : {}),\n soldOut:\n variant.inventory != null &&\n Number(variant.inventory) <= 0 &&\n product.oversellPolicy !== 'backorder',\n ...(variant.imageUrl ? { imageUrl: variant.imageUrl } : {}),\n ...(variant.sku ? { sku: variant.sku } : {}),\n ...(CommerceModel.productAsksUseDate(product, variant.id) ? { asksUseDate: true as const } : {}),\n })),\n ...(asksUseDate ? { useDayTimeZone: options.timeZone || 'UTC' } : {}),\n ...(product.tags?.length ? { tags: product.tags } : {}),\n ...(product.subscription ? { subscription: product.subscription } : {}),\n ...(product.subscription && product.subscriptionOptional\n ? { subscriptionOptional: true }\n : {}),\n ...(modifierGroups.length ? { modifierGroups } : {}),\n }\n}\n\n/** Whether any variant of a product asks the buyer for a use day (AGL-4043). */\nexport function productAsksUseDateAnywhere(\n product: Pick<ReturnType<typeof CommerceModel.liftLegacyProduct>, 'variants' | 'checkIn'>,\n): boolean {\n return product.variants.some((variant) => CommerceModel.productAsksUseDate(product, variant.id))\n}\n\n/**\n * Public product detail by slug (AGL-292): everything the PDP block\n * needs — variants with per-option prices and stock booleans, never raw\n * counts. Display-only; the charge still prices server-side.\n */\nexport const productHandler: PluginApiHandler = async (req, res) => {\n const hostId = String(req.query.hostId ?? '')\n const slug = String(req.query.slug ?? '')\n if (!hostId || !slug) {\n return res.status(400).json({ error: 'Missing hostId or slug' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('products')\n .where('slug', '==', slug)\n .limit(1)\n .get()\n const docSnapshot = snapshot.docs[0]\n const raw = docSnapshot?.data() as any\n // A product sold in person only (AGL-4060) has no page online: the same\n // 404 as a draft, so its address says nothing about it.\n if (!CommerceModel.productListedOnline(raw)) {\n return res.status(404).json({ error: 'Unknown product' })\n }\n const product = CommerceModel.liftLegacyProduct(raw)\n // The store's calendar, only for a product that asks for a use day.\n const timeZone = productAsksUseDateAnywhere(product)\n ? (await readStoreDay(firestore.collection('hosts').doc(hostId))).timeZone\n : null\n const detail = toPublicProductDetail(docSnapshot.id, product, { timeZone })\n res.setHeader(\n 'Cache-Control',\n 'public, s-maxage=60, stale-while-revalidate=300',\n )\n return res.status(200).json({ product: detail })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Product unavailable' })\n }\n}\n"],"names":["CommerceModel","firebaseAdmin","readStoreDay","publicModifierGroups","product","websiteModifierGroups","map","group","id","name","min","max","options","option","priceCents","priceCentsByVariant","Object","keys","length","toPublicProductDetail","asksUseDate","productAsksUseDateAnywhere","modifierGroups","slug","description","type","mediaUrls","imageUrl","variants","variant","priceUsd","variantHasPrice","compareAtPriceUsd","soldOut","inventory","Number","oversellPolicy","sku","productAsksUseDate","useDayTimeZone","timeZone","tags","subscription","subscriptionOptional","some","productHandler","req","res","hostId","String","query","status","json","error","firestore","app","snapshot","collection","doc","where","limit","get","docSnapshot","docs","raw","data","productListedOnline","liftLegacyProduct","detail","setHeader","console"],"mappings":"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"}
@@ -22,7 +22,7 @@ import { countLiveDocuments } from "@aglyn/aglyn/app-utils/live-document-count";
22
22
  import { dropPluginSiteCache } from "@aglyn/aglyn/plugin-manager/plugin-site-cache";
23
23
  import { firebaseAdmin, getLockdownVerdict, getOrgForHost, isImpersonationSession } from "@aglyn/tenant-data-admin";
24
24
  import { FieldValue } from "firebase-admin/firestore";
25
- import { liftLegacyProduct, productCollectionIds, productDuplicateFields, productDuplicateName, productDuplicateSlugBase, productEditWrite, productMembershipInput, productSaveFields, smartCollectionRulesOf, stockAdjustmentWrite, validateProduct } from "../model/index.js";
25
+ import { liftLegacyProduct, productCollectionIds, productDuplicateFields, productDuplicateName, productDuplicateSlugBase, productEditWrite, productMembershipInput, productSaveFields, smartCollectionRulesOf, stockAdjustmentWrite, validateProduct, variantMatrixProblem } from "../model/index.js";
26
26
  import { planRefusal, roomRefusal } from "./product-drafts.js";
27
27
  /** Names "Edit site content" (`site.edit`, AGL-4047), never a role. */ export const ROLE_REFUSAL = siteKeyRefusal('site.edit');
28
28
  const MAX_BODY_BYTES = 256000;
@@ -173,11 +173,14 @@ export function resolved(deps) {
173
173
  error: 'Product name is required'
174
174
  });
175
175
  try {
176
+ var _variantMatrixProblem;
176
177
  const door = await gate(req, res, hostId, deps);
177
178
  if (!door) return;
178
179
  const nowMs = deps.now();
179
180
  const fields = productSaveFields(product, nowMs);
180
- const problem = validateProduct(fields);
181
+ // More combinations than a product holds is refused with the count
182
+ // (AGL-4100), never saved short of the options it names.
183
+ const problem = (_variantMatrixProblem = variantMatrixProblem(fields.options)) != null ? _variantMatrixProblem : validateProduct(fields);
181
184
  if (problem) return res.status(400).json({
182
185
  error: problem
183
186
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/products-write.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash } from 'node:crypto'\nimport { holdsSiteKey, hostStampedSitePermission, siteKeyRefusal } from '@aglyn/aglyn/app-utils/host-permissions'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport type { HostAccessRole } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport { countLiveDocuments } from '@aglyn/aglyn/app-utils/live-document-count'\nimport { dropPluginSiteCache } from '@aglyn/aglyn/plugin-manager/plugin-site-cache'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getLockdownVerdict, getOrgForHost, isImpersonationSession } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type EditedProduct,\n liftLegacyProduct,\n productCollectionIds,\n productDuplicateFields,\n productDuplicateName,\n productDuplicateSlugBase,\n productEditWrite,\n productMembershipInput,\n productSaveFields,\n smartCollectionRulesOf,\n stockAdjustmentWrite,\n validateProduct,\n type InventoryAdjustmentReason,\n} from '../model'\nimport { planRefusal, roomRefusal } from './product-drafts'\n\n/*\n * `POST /api/commerce/products/save` and `POST /api/commerce/products/stock`\n * (AGL-3652): the console product editor's save and its Adjust stock dialog,\n * for the native apps.\n *\n * The console makes both writes from the browser, computing derived fields\n * first (search keys, stock verdict, price, image, smart-collection\n * membership). These routes run THAT computation (`model/product-write.ts`,\n * which the console imports too) and make the same writes, under the gate\n * the Firestore rules put on them: a verified member with a write role on the\n * site (`canWriteHostContent`), or staff, while the site, its org and the\n * platform are not frozen (`hostWritesFrozen`). A new product also meets the\n * create path's own door: the plan's `commerce` entitlement and its\n * `productsPerHost` quota, counted in the same transaction as the create.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Names \"Edit site content\" (`site.edit`, AGL-4047), never a role. */\nexport const ROLE_REFUSAL = siteKeyRefusal('site.edit')\nconst MAX_BODY_BYTES = 256_000\n\n/** The reasons the Adjust stock dialog offers. */\nexport const STOCK_ADJUST_REASONS: readonly InventoryAdjustmentReason[] = ['restock', 'correction', 'damage', 'refund']\n\nexport interface ProductsWriteDeps {\n firestore?: () => Firestore\n verifyIdToken?: (token: string) => Promise<Record<string, unknown> & { uid: string }>\n orgForHost?: (hostId: string) => Promise<{ org: Record<string, unknown> } | null>\n lockdown?: (options: Parameters<typeof getLockdownVerdict>[0]) => Promise<unknown>\n dropCache?: typeof dropPluginSiteCache\n now?: () => number\n}\n\ntype Reply = Parameters<PluginApiHandler>[1]\n\nexport function bodyOf(req: Parameters<PluginApiHandler>[0]): Record<string, unknown> {\n const raw = req.body\n if (typeof raw === 'string') {\n if (raw.length > MAX_BODY_BYTES) return {}\n try {\n return JSON.parse(raw || '{}')\n } catch {\n return {}\n }\n }\n return (raw ?? {}) as Record<string, unknown>\n}\n\nexport const validId = (value: unknown): value is string =>\n typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)\n\nexport interface Gate {\n uid: string\n /** The caller's address, for an activity line; null when the token has none. */\n email: string | null\n staff: boolean\n host: FirebaseFirestore.DocumentSnapshot\n org: Record<string, unknown>\n}\n\n/**\n * The permission a route built on {@link gate} checks instead of the site\n * role (AGL-4047): a key whose preset is the site role, that preset (the\n * declaring plugin's own table, so the answer does not depend on which\n * plugins this process loaded), and the refusal naming it.\n */\nexport interface GatePermission {\n key: string\n presetFor: Readonly<Record<HostAccessRole, boolean>>\n refusal: string\n}\n\n/**\n * Who may write this site's products, as the rules decide it. Answers the\n * caller's reply on a refusal and returns null.\n *\n * A route that is not about products names the key it checks instead\n * (`permission`, AGL-4047): the walk-in and the guests change ask their\n * reservation key, read as the rules read it, from the site's\n * `memberPermissions` stamp, else the preset of the member's `memberRoles`\n * entry, so the route and the rules give one answer. Without it, the\n * product routes ask \"Edit site content\" (`site.edit`) the same way.\n */\nexport async function gate(\n req: Parameters<PluginApiHandler>[0],\n res: Reply,\n hostId: string,\n deps: Required<ProductsWriteDeps>,\n options: { permission?: GatePermission } = {},\n): Promise<Gate | null> {\n const authorization = String(req.headers.authorization ?? '')\n const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''\n if (!token) return void res.status(401).json({ error: 'Unauthenticated' }), null\n let decoded: Record<string, unknown> & { uid: string }\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // Only a refused token is the caller's; an Auth outage is a 500 (AGL-2852).\n if (!isRefusedIdToken(error)) throw error\n return void res.status(401).json({ error: 'Sign in again to continue' }), null\n }\n const staff = decoded['staff'] === true\n if (!staff && decoded['email_verified'] !== true && !isImpersonationSession(decoded as never)) {\n return void res.status(403).json({ error: 'Verify your email address to make changes' }), null\n }\n const host = await deps.firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return void res.status(404).json({ error: 'Unknown site' }), null\n if (!staff) {\n const { permission } = options\n const allowed = permission\n ? hostStampedSitePermission(host.data(), decoded.uid, permission.key, permission.presetFor)\n : holdsSiteKey(host, decoded.uid, 'site.edit')\n if (!allowed) return void res.status(403).json({ error: permission?.refusal ?? ROLE_REFUSAL }), null\n }\n const org = ((await deps.orgForHost(hostId))?.org ?? {}) as Record<string, unknown>\n const locked = await deps.lockdown({ staff, uid: decoded.uid, org: org as never, host: host.data() as never, request: { method: 'POST' } as never })\n if (locked) return void res.status(423).json({ error: 'This site is not accepting changes right now' }), null\n const email = typeof decoded['email'] === 'string' ? decoded['email'] : null\n return { uid: decoded.uid, email, staff, host, org }\n}\n\n/** The site-wide cache drop a product change is owed (AGL-3386): an outbox entry in the write, then the drop. */\nexport function outboxEntry(firestore: Firestore, hostId: string) {\n return {\n ref: firestore.collection('publishOutbox').doc(createResourceUid()),\n data: { hostId, paths: ['/'], createdAt: FieldValue.serverTimestamp(), attempts: 0, entireHost: true },\n }\n}\n\nexport async function settleCache(deps: Required<ProductsWriteDeps>, hostId: string, entry: FirebaseFirestore.DocumentReference, reason: string) {\n const dropped = await deps.dropCache({ hostIds: [hostId], reason }).catch(() => ({ complete: false }))\n if (dropped.complete) await entry.delete().catch(() => undefined)\n}\n\nexport function resolved(deps: ProductsWriteDeps): Required<ProductsWriteDeps> {\n return {\n firestore: deps.firestore ?? (() => firebaseAdmin.app().firestore() as unknown as Firestore),\n verifyIdToken: deps.verifyIdToken ?? ((token) => firebaseAdmin.app().auth().verifyIdToken(token) as never),\n orgForHost: deps.orgForHost ?? ((hostId) => getOrgForHost(hostId) as never),\n lockdown: deps.lockdown ?? ((options) => getLockdownVerdict(options)),\n dropCache: deps.dropCache ?? dropPluginSiteCache,\n now: deps.now ?? (() => Date.now()),\n }\n}\n\n/**\n * Saves a product as the console editor does. `create: true` makes a new\n * product under the client-minted `productId` (a retry finds its own product\n * and answers `replayed`); otherwise it replaces the live product, keeping\n * its live stock holds.\n */\nexport function createProductSaveHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const hostId = body['hostId']\n const productId = body['productId']\n const product = body['product'] as EditedProduct | undefined\n if (!validId(hostId) || !validId(productId) || !product || typeof product !== 'object' || !Array.isArray(product.variants)) {\n return res.status(400).json({ error: 'Missing site, product or its variants' })\n }\n if (typeof product.name !== 'string' || !product.name.trim()) return res.status(400).json({ error: 'Product name is required' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const nowMs = deps.now()\n const fields = productSaveFields(product, nowMs)\n const problem = validateProduct(fields as never)\n if (problem) return res.status(400).json({ error: problem })\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const products = hostRef.collection('products')\n const productRef = products.doc(productId)\n const smartQuery = hostRef.collection('collections').where('kind', '==', 'catalog').where('mode', '==', 'smart')\n const outbox = outboxEntry(db, hostId)\n const create = body['create'] === true\n const answer = await db.runTransaction(async (tx) => {\n const [existing, smart] = await Promise.all([tx.get(productRef), tx.get(smartQuery)])\n const membership = {\n collectionIds: productCollectionIds(\n productMembershipInput(fields, product) as never,\n smart.docs.map((doc) => smartCollectionRulesOf(doc.id, doc.data())),\n ),\n }\n if (create) {\n if (existing.exists) return { status: 200, body: { ok: true, id: productId, replayed: true } }\n const early = planRefusal(door.org as never)\n if (early) return { status: early.status, body: { error: early.error } }\n const used = await countLiveDocuments(products, (aggregate) => tx.get(aggregate))\n const room = roomRefusal(door.org as never, Number(used) || 0)\n if (room) return { status: room.status, body: { error: room.error } }\n tx.create(productRef, {\n ...fields,\n ...membership,\n createdAtMs: nowMs,\n deletedAt: null,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: door.uid,\n })\n } else {\n if (!existing.exists || existing.get('deletedAt')) return { status: 404, body: { error: 'This product was deleted' } }\n tx.set(productRef, {\n ...storedTimestamps(existing.data() ?? {}, product as unknown as Record<string, unknown>),\n ...productEditWrite(fields, existing.get('stockHolds')),\n ...membership,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, id: productId, replayed: false } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'product saved from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/save]', error)\n return res.status(500).json({ error: 'The product could not be saved' })\n }\n }\n}\n\n/**\n * The stored timestamps an edit keeps. The console's replace writes back the\n * Timestamps its seed carried (`createdAt`, a publish time); a client's JSON\n * cannot carry one, so each stored Timestamp field the request does not name\n * is kept as stored.\n */\nexport function storedTimestamps(stored: Record<string, unknown>, asked: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(stored).filter(\n ([key, value]) => !(key in asked) && typeof (value as { toDate?: unknown } | null)?.toDate === 'function',\n ),\n )\n}\n\n/** The ledger row id one attempt owns: the same key from the same member is the same row. */\nexport function stockAttemptId(uid: string, key: string): string {\n return 'app-' + createHash('sha256').update(`${uid}:${key}`).digest('hex').slice(0, 40)\n}\n\n/**\n * Adjusts one variant's stock as the Adjust stock dialog does: the variants\n * and stock fields on the product, and one `inventoryAdjustments` row, in one\n * transaction. The Idempotency-Key names that row, so a retried attempt finds\n * it and moves nothing twice.\n */\nexport function createProductStockHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const hostId = body['hostId']\n const productId = body['productId']\n const variantId = body['variantId']\n const key = String(req.headers['idempotency-key'] ?? '').trim().slice(0, 200)\n const reason = body['reason'] as InventoryAdjustmentReason\n const delta = Math.round(Number(body['delta']))\n const locationId = typeof body['locationId'] === 'string' && body['locationId'] ? body['locationId'] : undefined\n if (!validId(hostId) || !validId(productId) || typeof variantId !== 'string' || !variantId) {\n return res.status(400).json({ error: 'Missing site, product or variant' })\n }\n if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key' })\n if (!STOCK_ADJUST_REASONS.includes(reason)) return res.status(400).json({ error: 'Unknown reason' })\n if (!Number.isFinite(delta) || delta === 0) return res.status(400).json({ error: 'Enter a number of units' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const productRef = hostRef.collection('products').doc(productId)\n const ledgerRef = hostRef.collection('inventoryAdjustments').doc(stockAttemptId(door.uid, key))\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const [ledger, stored] = await Promise.all([tx.get(ledgerRef), tx.get(productRef)])\n if (ledger.exists) {\n return { status: 200, body: { ok: true, replayed: true, inventory: stored.get('inventory') ?? null } }\n }\n if (!stored.exists || stored.get('deletedAt')) return { status: 404, body: { error: 'This product was deleted' } }\n const product = stored.data() as never as Parameters<typeof stockAdjustmentWrite>[0]\n if (!(product.variants ?? []).some((variant) => variant.id === variantId)) {\n return { status: 404, body: { error: 'That variant is no longer on this product' } }\n }\n const write = stockAdjustmentWrite(product, productId, { variantId, delta, reason, locationId }, deps.now())\n if (!write) return { status: 400, body: { error: 'Enter a number of units' } }\n tx.update(productRef, write.update)\n tx.create(ledgerRef, write.ledger)\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, replayed: false, inventory: (write.update as { inventory?: unknown }).inventory ?? null } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'stock adjusted from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/stock]', error)\n return res.status(500).json({ error: 'Stock could not be adjusted' })\n }\n }\n}\n\n/**\n * The first slug in `base`, `base-2`, `base-3`… no product of the site holds,\n * as the console's slug ledger claims one (`product-slugs.ts`): a second\n * Duplicate of the same product does not mint a second `-copy` (AGL-3321).\n */\nexport async function claimProductSlug(products: FirebaseFirestore.CollectionReference, base: string): Promise<string> {\n for (let suffix = 1; suffix < 1000; suffix += 1) {\n const candidate = suffix < 2 ? base : `${base}-${suffix}`\n const held = await products.where('slug', '==', candidate).limit(1).get()\n if (held.empty) return candidate\n }\n return `${base}-${Date.now()}`\n}\n\n/**\n * `POST /api/commerce/products/duplicate` (AGL-3841): the products list's\n * Duplicate for the native apps. The console builds the copy in the browser\n * (`productDuplicateFields`, its slug ledger, the copy's smart collections)\n * and creates it through the quota-enforcing resources route; this runs the\n * same model over the stored product and creates it under the save route's\n * create door (the plan's commerce entitlement and its product quota, counted\n * in the same transaction). `newProductId` is the client's, so a retry finds\n * its own copy and answers `replayed`.\n */\nexport function createProductDuplicateHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const { hostId, productId, newProductId } = body\n if (!validId(hostId) || !validId(productId) || !validId(newProductId) || productId === newProductId) {\n return res.status(400).json({ error: 'Missing site or product' })\n }\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const products = hostRef.collection('products')\n const copyRef = products.doc(newProductId)\n const already = await copyRef.get()\n if (already.exists) return res.status(200).json({ ok: true, id: newProductId, replayed: true })\n const source = await products.doc(productId).get()\n if (!source.exists || source.get('deletedAt')) return res.status(404).json({ error: 'This product was deleted' })\n const product = liftLegacyProduct((source.data() ?? {}) as never)\n const slug = await claimProductSlug(products, productDuplicateSlugBase(product.slug, product.name))\n const nowMs = deps.now()\n const fields = productDuplicateFields(product, slug, nowMs)\n const smartQuery = hostRef.collection('collections').where('kind', '==', 'catalog').where('mode', '==', 'smart')\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const [existing, smart] = await Promise.all([tx.get(copyRef), tx.get(smartQuery)])\n if (existing.exists) return { status: 200, body: { ok: true, id: newProductId, replayed: true } }\n const early = planRefusal(door.org as never)\n if (early) return { status: early.status, body: { error: early.error } }\n const used = await countLiveDocuments(products, (aggregate) => tx.get(aggregate))\n const room = roomRefusal(door.org as never, Number(used) || 0)\n if (room) return { status: room.status, body: { error: room.error } }\n // The COPY's smart collections: its name differs from the source's.\n const collectionIds = productCollectionIds(\n productMembershipInput({ name: productDuplicateName(product.name) }, product) as never,\n smart.docs.map((doc) => smartCollectionRulesOf(doc.id, doc.data())),\n )\n tx.create(copyRef, {\n ...fields,\n collectionIds,\n deletedAt: null,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: door.uid,\n })\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, id: newProductId, replayed: false, slug } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'product duplicated from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/duplicate]', error)\n return res.status(500).json({ error: 'Could not duplicate product' })\n }\n }\n}\n\n/**\n * `POST /api/commerce/products/delete` (AGL-3841): the products list's Delete\n * for the native apps, the console's write (`deletedAt` on the product, which\n * stops it being purchasable) with the site-wide cache drop, under the save\n * route's gate. Deleting a deleted product answers `already`.\n */\nexport function createProductDeleteHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const { hostId, productId } = body\n if (!validId(hostId) || !validId(productId)) return res.status(400).json({ error: 'Missing site or product' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const productRef = db.collection('hosts').doc(hostId).collection('products').doc(productId)\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const stored = await tx.get(productRef)\n if (!stored.exists) return { status: 404, body: { error: 'This product no longer exists' } }\n if (stored.get('deletedAt')) return { status: 200, body: { ok: true, already: true } }\n tx.update(productRef, { deletedAt: FieldValue.serverTimestamp() })\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, already: false } }\n })\n if (answer.status === 200 && !(answer.body as { already?: boolean }).already) {\n await settleCache(deps, hostId, outbox.ref, 'product deleted from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/delete]', error)\n return res.status(500).json({ error: 'Could not delete product' })\n }\n }\n}\n\nexport const productSaveHandler = createProductSaveHandler()\nexport const productStockHandler = createProductStockHandler()\nexport const productDuplicateHandler = createProductDuplicateHandler()\nexport const productDeleteHandler = createProductDeleteHandler()\n"],"names":["createHash","holdsSiteKey","hostStampedSitePermission","siteKeyRefusal","isRefusedIdToken","createResourceUid","countLiveDocuments","dropPluginSiteCache","firebaseAdmin","getLockdownVerdict","getOrgForHost","isImpersonationSession","FieldValue","liftLegacyProduct","productCollectionIds","productDuplicateFields","productDuplicateName","productDuplicateSlugBase","productEditWrite","productMembershipInput","productSaveFields","smartCollectionRulesOf","stockAdjustmentWrite","validateProduct","planRefusal","roomRefusal","ROLE_REFUSAL","MAX_BODY_BYTES","STOCK_ADJUST_REASONS","bodyOf","req","raw","body","length","JSON","parse","validId","value","test","gate","res","hostId","deps","options","authorization","String","headers","token","startsWith","slice","status","json","error","decoded","verifyIdToken","staff","host","firestore","collection","doc","get","exists","permission","allowed","data","uid","key","presetFor","refusal","org","orgForHost","locked","lockdown","request","method","email","outboxEntry","ref","paths","createdAt","serverTimestamp","attempts","entireHost","settleCache","entry","reason","dropped","dropCache","hostIds","catch","complete","delete","undefined","resolved","app","auth","now","Date","createProductSaveHandler","productId","product","Array","isArray","variants","name","trim","door","nowMs","fields","problem","db","hostRef","products","productRef","smartQuery","where","outbox","create","answer","runTransaction","tx","existing","smart","Promise","all","membership","collectionIds","docs","map","id","ok","replayed","early","used","aggregate","room","Number","createdAtMs","deletedAt","updatedAt","createdBy","set","storedTimestamps","console","stored","asked","Object","fromEntries","entries","filter","toDate","stockAttemptId","update","digest","createProductStockHandler","variantId","delta","Math","round","locationId","includes","isFinite","ledgerRef","ledger","inventory","some","variant","write","claimProductSlug","base","suffix","candidate","held","limit","empty","createProductDuplicateHandler","newProductId","source","copyRef","already","slug","createProductDeleteHandler","productSaveHandler","productStockHandler","productDuplicateHandler","productDeleteHandler"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/products-write.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHash } from 'node:crypto'\nimport { holdsSiteKey, hostStampedSitePermission, siteKeyRefusal } from '@aglyn/aglyn/app-utils/host-permissions'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport type { HostAccessRole } from '@aglyn/aglyn/foundation/definitions/organization.types'\nimport { countLiveDocuments } from '@aglyn/aglyn/app-utils/live-document-count'\nimport { dropPluginSiteCache } from '@aglyn/aglyn/plugin-manager/plugin-site-cache'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getLockdownVerdict, getOrgForHost, isImpersonationSession } from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type EditedProduct,\n liftLegacyProduct,\n productCollectionIds,\n productDuplicateFields,\n productDuplicateName,\n productDuplicateSlugBase,\n productEditWrite,\n productMembershipInput,\n productSaveFields,\n smartCollectionRulesOf,\n stockAdjustmentWrite,\n validateProduct,\n variantMatrixProblem,\n type InventoryAdjustmentReason,\n} from '../model'\nimport { planRefusal, roomRefusal } from './product-drafts'\n\n/*\n * `POST /api/commerce/products/save` and `POST /api/commerce/products/stock`\n * (AGL-3652): the console product editor's save and its Adjust stock dialog,\n * for the native apps.\n *\n * The console makes both writes from the browser, computing derived fields\n * first (search keys, stock verdict, price, image, smart-collection\n * membership). These routes run THAT computation (`model/product-write.ts`,\n * which the console imports too) and make the same writes, under the gate\n * the Firestore rules put on them: a verified member with a write role on the\n * site (`canWriteHostContent`), or staff, while the site, its org and the\n * platform are not frozen (`hostWritesFrozen`). A new product also meets the\n * create path's own door: the plan's `commerce` entitlement and its\n * `productsPerHost` quota, counted in the same transaction as the create.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Names \"Edit site content\" (`site.edit`, AGL-4047), never a role. */\nexport const ROLE_REFUSAL = siteKeyRefusal('site.edit')\nconst MAX_BODY_BYTES = 256_000\n\n/** The reasons the Adjust stock dialog offers. */\nexport const STOCK_ADJUST_REASONS: readonly InventoryAdjustmentReason[] = ['restock', 'correction', 'damage', 'refund']\n\nexport interface ProductsWriteDeps {\n firestore?: () => Firestore\n verifyIdToken?: (token: string) => Promise<Record<string, unknown> & { uid: string }>\n orgForHost?: (hostId: string) => Promise<{ org: Record<string, unknown> } | null>\n lockdown?: (options: Parameters<typeof getLockdownVerdict>[0]) => Promise<unknown>\n dropCache?: typeof dropPluginSiteCache\n now?: () => number\n}\n\ntype Reply = Parameters<PluginApiHandler>[1]\n\nexport function bodyOf(req: Parameters<PluginApiHandler>[0]): Record<string, unknown> {\n const raw = req.body\n if (typeof raw === 'string') {\n if (raw.length > MAX_BODY_BYTES) return {}\n try {\n return JSON.parse(raw || '{}')\n } catch {\n return {}\n }\n }\n return (raw ?? {}) as Record<string, unknown>\n}\n\nexport const validId = (value: unknown): value is string =>\n typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)\n\nexport interface Gate {\n uid: string\n /** The caller's address, for an activity line; null when the token has none. */\n email: string | null\n staff: boolean\n host: FirebaseFirestore.DocumentSnapshot\n org: Record<string, unknown>\n}\n\n/**\n * The permission a route built on {@link gate} checks instead of the site\n * role (AGL-4047): a key whose preset is the site role, that preset (the\n * declaring plugin's own table, so the answer does not depend on which\n * plugins this process loaded), and the refusal naming it.\n */\nexport interface GatePermission {\n key: string\n presetFor: Readonly<Record<HostAccessRole, boolean>>\n refusal: string\n}\n\n/**\n * Who may write this site's products, as the rules decide it. Answers the\n * caller's reply on a refusal and returns null.\n *\n * A route that is not about products names the key it checks instead\n * (`permission`, AGL-4047): the walk-in and the guests change ask their\n * reservation key, read as the rules read it, from the site's\n * `memberPermissions` stamp, else the preset of the member's `memberRoles`\n * entry, so the route and the rules give one answer. Without it, the\n * product routes ask \"Edit site content\" (`site.edit`) the same way.\n */\nexport async function gate(\n req: Parameters<PluginApiHandler>[0],\n res: Reply,\n hostId: string,\n deps: Required<ProductsWriteDeps>,\n options: { permission?: GatePermission } = {},\n): Promise<Gate | null> {\n const authorization = String(req.headers.authorization ?? '')\n const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''\n if (!token) return void res.status(401).json({ error: 'Unauthenticated' }), null\n let decoded: Record<string, unknown> & { uid: string }\n try {\n decoded = await deps.verifyIdToken(token)\n } catch (error) {\n // Only a refused token is the caller's; an Auth outage is a 500 (AGL-2852).\n if (!isRefusedIdToken(error)) throw error\n return void res.status(401).json({ error: 'Sign in again to continue' }), null\n }\n const staff = decoded['staff'] === true\n if (!staff && decoded['email_verified'] !== true && !isImpersonationSession(decoded as never)) {\n return void res.status(403).json({ error: 'Verify your email address to make changes' }), null\n }\n const host = await deps.firestore().collection('hosts').doc(hostId).get()\n if (!host.exists) return void res.status(404).json({ error: 'Unknown site' }), null\n if (!staff) {\n const { permission } = options\n const allowed = permission\n ? hostStampedSitePermission(host.data(), decoded.uid, permission.key, permission.presetFor)\n : holdsSiteKey(host, decoded.uid, 'site.edit')\n if (!allowed) return void res.status(403).json({ error: permission?.refusal ?? ROLE_REFUSAL }), null\n }\n const org = ((await deps.orgForHost(hostId))?.org ?? {}) as Record<string, unknown>\n const locked = await deps.lockdown({ staff, uid: decoded.uid, org: org as never, host: host.data() as never, request: { method: 'POST' } as never })\n if (locked) return void res.status(423).json({ error: 'This site is not accepting changes right now' }), null\n const email = typeof decoded['email'] === 'string' ? decoded['email'] : null\n return { uid: decoded.uid, email, staff, host, org }\n}\n\n/** The site-wide cache drop a product change is owed (AGL-3386): an outbox entry in the write, then the drop. */\nexport function outboxEntry(firestore: Firestore, hostId: string) {\n return {\n ref: firestore.collection('publishOutbox').doc(createResourceUid()),\n data: { hostId, paths: ['/'], createdAt: FieldValue.serverTimestamp(), attempts: 0, entireHost: true },\n }\n}\n\nexport async function settleCache(deps: Required<ProductsWriteDeps>, hostId: string, entry: FirebaseFirestore.DocumentReference, reason: string) {\n const dropped = await deps.dropCache({ hostIds: [hostId], reason }).catch(() => ({ complete: false }))\n if (dropped.complete) await entry.delete().catch(() => undefined)\n}\n\nexport function resolved(deps: ProductsWriteDeps): Required<ProductsWriteDeps> {\n return {\n firestore: deps.firestore ?? (() => firebaseAdmin.app().firestore() as unknown as Firestore),\n verifyIdToken: deps.verifyIdToken ?? ((token) => firebaseAdmin.app().auth().verifyIdToken(token) as never),\n orgForHost: deps.orgForHost ?? ((hostId) => getOrgForHost(hostId) as never),\n lockdown: deps.lockdown ?? ((options) => getLockdownVerdict(options)),\n dropCache: deps.dropCache ?? dropPluginSiteCache,\n now: deps.now ?? (() => Date.now()),\n }\n}\n\n/**\n * Saves a product as the console editor does. `create: true` makes a new\n * product under the client-minted `productId` (a retry finds its own product\n * and answers `replayed`); otherwise it replaces the live product, keeping\n * its live stock holds.\n */\nexport function createProductSaveHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const hostId = body['hostId']\n const productId = body['productId']\n const product = body['product'] as EditedProduct | undefined\n if (!validId(hostId) || !validId(productId) || !product || typeof product !== 'object' || !Array.isArray(product.variants)) {\n return res.status(400).json({ error: 'Missing site, product or its variants' })\n }\n if (typeof product.name !== 'string' || !product.name.trim()) return res.status(400).json({ error: 'Product name is required' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const nowMs = deps.now()\n const fields = productSaveFields(product, nowMs)\n // More combinations than a product holds is refused with the count\n // (AGL-4100), never saved short of the options it names.\n const problem = variantMatrixProblem(fields.options) ?? validateProduct(fields as never)\n if (problem) return res.status(400).json({ error: problem })\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const products = hostRef.collection('products')\n const productRef = products.doc(productId)\n const smartQuery = hostRef.collection('collections').where('kind', '==', 'catalog').where('mode', '==', 'smart')\n const outbox = outboxEntry(db, hostId)\n const create = body['create'] === true\n const answer = await db.runTransaction(async (tx) => {\n const [existing, smart] = await Promise.all([tx.get(productRef), tx.get(smartQuery)])\n const membership = {\n collectionIds: productCollectionIds(\n productMembershipInput(fields, product) as never,\n smart.docs.map((doc) => smartCollectionRulesOf(doc.id, doc.data())),\n ),\n }\n if (create) {\n if (existing.exists) return { status: 200, body: { ok: true, id: productId, replayed: true } }\n const early = planRefusal(door.org as never)\n if (early) return { status: early.status, body: { error: early.error } }\n const used = await countLiveDocuments(products, (aggregate) => tx.get(aggregate))\n const room = roomRefusal(door.org as never, Number(used) || 0)\n if (room) return { status: room.status, body: { error: room.error } }\n tx.create(productRef, {\n ...fields,\n ...membership,\n createdAtMs: nowMs,\n deletedAt: null,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: door.uid,\n })\n } else {\n if (!existing.exists || existing.get('deletedAt')) return { status: 404, body: { error: 'This product was deleted' } }\n tx.set(productRef, {\n ...storedTimestamps(existing.data() ?? {}, product as unknown as Record<string, unknown>),\n ...productEditWrite(fields, existing.get('stockHolds')),\n ...membership,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, id: productId, replayed: false } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'product saved from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/save]', error)\n return res.status(500).json({ error: 'The product could not be saved' })\n }\n }\n}\n\n/**\n * The stored timestamps an edit keeps. The console's replace writes back the\n * Timestamps its seed carried (`createdAt`, a publish time); a client's JSON\n * cannot carry one, so each stored Timestamp field the request does not name\n * is kept as stored.\n */\nexport function storedTimestamps(stored: Record<string, unknown>, asked: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(stored).filter(\n ([key, value]) => !(key in asked) && typeof (value as { toDate?: unknown } | null)?.toDate === 'function',\n ),\n )\n}\n\n/** The ledger row id one attempt owns: the same key from the same member is the same row. */\nexport function stockAttemptId(uid: string, key: string): string {\n return 'app-' + createHash('sha256').update(`${uid}:${key}`).digest('hex').slice(0, 40)\n}\n\n/**\n * Adjusts one variant's stock as the Adjust stock dialog does: the variants\n * and stock fields on the product, and one `inventoryAdjustments` row, in one\n * transaction. The Idempotency-Key names that row, so a retried attempt finds\n * it and moves nothing twice.\n */\nexport function createProductStockHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const hostId = body['hostId']\n const productId = body['productId']\n const variantId = body['variantId']\n const key = String(req.headers['idempotency-key'] ?? '').trim().slice(0, 200)\n const reason = body['reason'] as InventoryAdjustmentReason\n const delta = Math.round(Number(body['delta']))\n const locationId = typeof body['locationId'] === 'string' && body['locationId'] ? body['locationId'] : undefined\n if (!validId(hostId) || !validId(productId) || typeof variantId !== 'string' || !variantId) {\n return res.status(400).json({ error: 'Missing site, product or variant' })\n }\n if (!key) return res.status(400).json({ error: 'Missing Idempotency-Key' })\n if (!STOCK_ADJUST_REASONS.includes(reason)) return res.status(400).json({ error: 'Unknown reason' })\n if (!Number.isFinite(delta) || delta === 0) return res.status(400).json({ error: 'Enter a number of units' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const productRef = hostRef.collection('products').doc(productId)\n const ledgerRef = hostRef.collection('inventoryAdjustments').doc(stockAttemptId(door.uid, key))\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const [ledger, stored] = await Promise.all([tx.get(ledgerRef), tx.get(productRef)])\n if (ledger.exists) {\n return { status: 200, body: { ok: true, replayed: true, inventory: stored.get('inventory') ?? null } }\n }\n if (!stored.exists || stored.get('deletedAt')) return { status: 404, body: { error: 'This product was deleted' } }\n const product = stored.data() as never as Parameters<typeof stockAdjustmentWrite>[0]\n if (!(product.variants ?? []).some((variant) => variant.id === variantId)) {\n return { status: 404, body: { error: 'That variant is no longer on this product' } }\n }\n const write = stockAdjustmentWrite(product, productId, { variantId, delta, reason, locationId }, deps.now())\n if (!write) return { status: 400, body: { error: 'Enter a number of units' } }\n tx.update(productRef, write.update)\n tx.create(ledgerRef, write.ledger)\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, replayed: false, inventory: (write.update as { inventory?: unknown }).inventory ?? null } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'stock adjusted from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/stock]', error)\n return res.status(500).json({ error: 'Stock could not be adjusted' })\n }\n }\n}\n\n/**\n * The first slug in `base`, `base-2`, `base-3`… no product of the site holds,\n * as the console's slug ledger claims one (`product-slugs.ts`): a second\n * Duplicate of the same product does not mint a second `-copy` (AGL-3321).\n */\nexport async function claimProductSlug(products: FirebaseFirestore.CollectionReference, base: string): Promise<string> {\n for (let suffix = 1; suffix < 1000; suffix += 1) {\n const candidate = suffix < 2 ? base : `${base}-${suffix}`\n const held = await products.where('slug', '==', candidate).limit(1).get()\n if (held.empty) return candidate\n }\n return `${base}-${Date.now()}`\n}\n\n/**\n * `POST /api/commerce/products/duplicate` (AGL-3841): the products list's\n * Duplicate for the native apps. The console builds the copy in the browser\n * (`productDuplicateFields`, its slug ledger, the copy's smart collections)\n * and creates it through the quota-enforcing resources route; this runs the\n * same model over the stored product and creates it under the save route's\n * create door (the plan's commerce entitlement and its product quota, counted\n * in the same transaction). `newProductId` is the client's, so a retry finds\n * its own copy and answers `replayed`.\n */\nexport function createProductDuplicateHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const { hostId, productId, newProductId } = body\n if (!validId(hostId) || !validId(productId) || !validId(newProductId) || productId === newProductId) {\n return res.status(400).json({ error: 'Missing site or product' })\n }\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const products = hostRef.collection('products')\n const copyRef = products.doc(newProductId)\n const already = await copyRef.get()\n if (already.exists) return res.status(200).json({ ok: true, id: newProductId, replayed: true })\n const source = await products.doc(productId).get()\n if (!source.exists || source.get('deletedAt')) return res.status(404).json({ error: 'This product was deleted' })\n const product = liftLegacyProduct((source.data() ?? {}) as never)\n const slug = await claimProductSlug(products, productDuplicateSlugBase(product.slug, product.name))\n const nowMs = deps.now()\n const fields = productDuplicateFields(product, slug, nowMs)\n const smartQuery = hostRef.collection('collections').where('kind', '==', 'catalog').where('mode', '==', 'smart')\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const [existing, smart] = await Promise.all([tx.get(copyRef), tx.get(smartQuery)])\n if (existing.exists) return { status: 200, body: { ok: true, id: newProductId, replayed: true } }\n const early = planRefusal(door.org as never)\n if (early) return { status: early.status, body: { error: early.error } }\n const used = await countLiveDocuments(products, (aggregate) => tx.get(aggregate))\n const room = roomRefusal(door.org as never, Number(used) || 0)\n if (room) return { status: room.status, body: { error: room.error } }\n // The COPY's smart collections: its name differs from the source's.\n const collectionIds = productCollectionIds(\n productMembershipInput({ name: productDuplicateName(product.name) }, product) as never,\n smart.docs.map((doc) => smartCollectionRulesOf(doc.id, doc.data())),\n )\n tx.create(copyRef, {\n ...fields,\n collectionIds,\n deletedAt: null,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: door.uid,\n })\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, id: newProductId, replayed: false, slug } }\n })\n if (answer.status === 200 && !(answer.body as { replayed?: boolean }).replayed) {\n await settleCache(deps, hostId, outbox.ref, 'product duplicated from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/duplicate]', error)\n return res.status(500).json({ error: 'Could not duplicate product' })\n }\n }\n}\n\n/**\n * `POST /api/commerce/products/delete` (AGL-3841): the products list's Delete\n * for the native apps, the console's write (`deletedAt` on the product, which\n * stops it being purchasable) with the site-wide cache drop, under the save\n * route's gate. Deleting a deleted product answers `already`.\n */\nexport function createProductDeleteHandler(options: ProductsWriteDeps = {}): PluginApiHandler {\n return async (req, res) => {\n if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' })\n const deps = resolved(options)\n const body = bodyOf(req)\n const { hostId, productId } = body\n if (!validId(hostId) || !validId(productId)) return res.status(400).json({ error: 'Missing site or product' })\n try {\n const door = await gate(req, res, hostId, deps)\n if (!door) return\n const db = deps.firestore()\n const productRef = db.collection('hosts').doc(hostId).collection('products').doc(productId)\n const outbox = outboxEntry(db, hostId)\n const answer = await db.runTransaction(async (tx) => {\n const stored = await tx.get(productRef)\n if (!stored.exists) return { status: 404, body: { error: 'This product no longer exists' } }\n if (stored.get('deletedAt')) return { status: 200, body: { ok: true, already: true } }\n tx.update(productRef, { deletedAt: FieldValue.serverTimestamp() })\n tx.set(outbox.ref, outbox.data)\n return { status: 200, body: { ok: true, already: false } }\n })\n if (answer.status === 200 && !(answer.body as { already?: boolean }).already) {\n await settleCache(deps, hostId, outbox.ref, 'product deleted from the app')\n }\n return res.status(answer.status).json(answer.body)\n } catch (error) {\n console.error('[commerce/products/delete]', error)\n return res.status(500).json({ error: 'Could not delete product' })\n }\n }\n}\n\nexport const productSaveHandler = createProductSaveHandler()\nexport const productStockHandler = createProductStockHandler()\nexport const productDuplicateHandler = createProductDuplicateHandler()\nexport const productDeleteHandler = createProductDeleteHandler()\n"],"names":["createHash","holdsSiteKey","hostStampedSitePermission","siteKeyRefusal","isRefusedIdToken","createResourceUid","countLiveDocuments","dropPluginSiteCache","firebaseAdmin","getLockdownVerdict","getOrgForHost","isImpersonationSession","FieldValue","liftLegacyProduct","productCollectionIds","productDuplicateFields","productDuplicateName","productDuplicateSlugBase","productEditWrite","productMembershipInput","productSaveFields","smartCollectionRulesOf","stockAdjustmentWrite","validateProduct","variantMatrixProblem","planRefusal","roomRefusal","ROLE_REFUSAL","MAX_BODY_BYTES","STOCK_ADJUST_REASONS","bodyOf","req","raw","body","length","JSON","parse","validId","value","test","gate","res","hostId","deps","options","authorization","String","headers","token","startsWith","slice","status","json","error","decoded","verifyIdToken","staff","host","firestore","collection","doc","get","exists","permission","allowed","data","uid","key","presetFor","refusal","org","orgForHost","locked","lockdown","request","method","email","outboxEntry","ref","paths","createdAt","serverTimestamp","attempts","entireHost","settleCache","entry","reason","dropped","dropCache","hostIds","catch","complete","delete","undefined","resolved","app","auth","now","Date","createProductSaveHandler","productId","product","Array","isArray","variants","name","trim","door","nowMs","fields","problem","db","hostRef","products","productRef","smartQuery","where","outbox","create","answer","runTransaction","tx","existing","smart","Promise","all","membership","collectionIds","docs","map","id","ok","replayed","early","used","aggregate","room","Number","createdAtMs","deletedAt","updatedAt","createdBy","set","storedTimestamps","console","stored","asked","Object","fromEntries","entries","filter","toDate","stockAttemptId","update","digest","createProductStockHandler","variantId","delta","Math","round","locationId","includes","isFinite","ledgerRef","ledger","inventory","some","variant","write","claimProductSlug","base","suffix","candidate","held","limit","empty","createProductDuplicateHandler","newProductId","source","copyRef","already","slug","createProductDeleteHandler","productSaveHandler","productStockHandler","productDuplicateHandler","productDeleteHandler"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { siteContactEmail, resolveHostToken, type HostTokenSource } from '@aglyn/aglyn/app-utils/host-tokens'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n emailThreadAfterSend,\n isEmailConfigured,\n mintEmailMessageId,\n nextThreadHeaders,\n PLATFORM_EMAIL_PALETTE,\n readEmailThread,\n resolveEmailColor,\n sendEmail,\n type EmailTheme,\n type RenderedHostEmail,\n type SendEmailOptions,\n type SendEmailResult,\n type SendingIdentityVerdict,\n} from '@aglyn/shared-util-email'\nimport {\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n siteEmailFrame,\n transactionalAddressSuppression,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { QUOTE_EMAIL_KEY, quoteEmailContent, type QuoteEmailColors } from '../utils/quote-email-content'\nimport type { quotePdfFile } from './commerce-pdf'\nimport type { CommercePdfFile } from './pdf-response'\n\n/*==========================================\n * SENDING THE QUOTE EMAIL (AGL-4097). Loaded only by the console's Send\n * quote (`POST commerce/quote { action: 'send' }`), so the renderer, the PDF\n * writer and sharp stay out of every other request.\n *\n * One email, the `quote-sent` catalog key in the site's theme and its header\n * and footer (or the site's own design of it), with the quote's PDF attached:\n *\n * - FROM the site's sending identity, under the site's name;\n * - REPLY-TO the site's contact email (its listing's, else its support\n * email), else the email of the person sending, so a reply reaches the\n * business either way, as an inbox reply's does;\n * - IN THE CUSTOMER'S CONVERSATION: a `Message-ID` minted on the sending\n * domain, and, when the quote already has a conversation (an earlier\n * send, or a request's confirmation), `In-Reply-To` and `References`\n * from it (`nextThreadHeaders`). The provider's own id is returned beside\n * ours, because whether a provider keeps a caller's `Message-ID` is its\n * own call, and a replaced one can then be traced;\n * - METERED as a site's transactional email;\n * - REFUSED only for an address that bounced for good or reported mail as\n * spam (`transactionalAddressSuppression`): the customer asked for the\n * quote, so an unsubscribe from the site's newsletter does not stop it,\n * as it does not stop a receipt (AGL-4098).\n *\n * The route decides everything about the quote (who may send, the link, the\n * stage); this module only says the email and sends it.\n *=========================================*/\n\n/** What the route hands over for one send. */\nexport interface QuoteDeliveryInput {\n hostId: string\n quoteId: string\n /** The quote as it stands, after any pay link replacement this send made. */\n order: CommerceModel.HostOrder\n host: Record<string, unknown>\n store: Record<string, unknown> | null\n owner: { id?: string; org?: unknown } | null\n to: string\n /** The subject as typed; empty takes the default. */\n subject: string\n message: string\n payUrl: string\n proofUrl: (proofId: string) => string | null\n linkReplaced: boolean\n /** The sending member's email: the Reply-To when the site has no contact email. */\n senderEmail: string | null\n atMs: number\n}\n\nexport type QuoteDeliveryResult =\n | {\n sent: true\n messageId: string\n providerId: string | null\n subject: string\n replyTo: string | null\n thread: CommerceModel.QuoteEmailThread\n }\n | { sent: false; status: number; error: string }\n\n/** The calls the send makes, injectable for specs. */\nexport interface QuoteMailerDeps {\n configured(): boolean\n sendingIdentity(hostId: string): Promise<SendingIdentityVerdict>\n render(hostId: string, key: string, tokens: Record<string, string>): Promise<RenderedHostEmail | null>\n /** The site's email theme, for the colors the composed proof markup draws with. */\n theme(hostId: string): Promise<EmailTheme | null>\n /** The quote's PDF; loads the PDF writer and sharp only here. */\n quotePdf(input: Parameters<typeof quotePdfFile>[0]): Promise<CommercePdfFile>\n send(options: SendEmailOptions): Promise<SendEmailResult>\n meter(hostId: string): Promise<void>\n /** The bounce and complaint list only: a quote is mail the customer asked for. */\n suppression(email: string): Promise<'suppressed-platform' | null>\n}\n\nexport function defaultQuoteMailerDeps(): QuoteMailerDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n configured: () => isEmailConfigured(),\n sendingIdentity: (hostId) => hostSendingIdentity(hostId),\n render: (hostId, key, tokens) => renderHostEmailWithTokens(firestore(), hostId, key, tokens),\n theme: async (hostId) => (await siteEmailFrame(firestore(), hostId, QUOTE_EMAIL_KEY)).theme,\n quotePdf: async (input) => (await import('./commerce-pdf')).quotePdfFile(input),\n send: (options) => sendEmail(options),\n meter: async (hostId) => {\n await meterHostEmail(hostId)\n },\n suppression: (email) => transactionalAddressSuppression(email),\n }\n}\n\n/** What the console's Send quote reaches: whether mail can go, whether this address may be mailed, and the send. */\nexport interface QuoteMailer {\n configured(): boolean\n suppression(email: string): Promise<'suppressed-platform' | null>\n deliver(input: QuoteDeliveryInput): Promise<QuoteDeliveryResult>\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** The business's name as its documents print it. */\nexport function quoteBusinessName(host: Record<string, unknown>): string {\n const named = resolveHostToken('businessName', host as HostTokenSource)\n return String(named ?? '').trim() || String(host['name'] ?? '').trim()\n}\n\n/**\n * Who a reply reaches: the site's contact email, else the person sending, as\n * an inbox reply answers to them. Null only when neither is an address.\n */\nexport function quoteReplyTo(host: Record<string, unknown>, senderEmail: string | null | undefined): string | null {\n const contact = siteContactEmail(host as never)\n if (contact && EMAIL.test(contact)) return contact\n const sender = String(senderEmail ?? '').trim()\n return EMAIL.test(sender) ? sender : null\n}\n\n/** The theme's colors for the composed proof markup, the platform's where the site's cannot answer. */\nfunction proofColors(theme: EmailTheme | null): QuoteEmailColors {\n const read = (token: string) =>\n resolveEmailColor(token, theme?.palette) ?? resolveEmailColor(token, PLATFORM_EMAIL_PALETTE) ?? 'inherit'\n return { border: read('divider'), muted: read('text.secondary') }\n}\n\n/** A refused send in the words the console shows. */\nfunction refusalOf(result: Extract<SendEmailResult, { sent: false }>): { status: number; error: string } {\n switch (result.reason) {\n case 'unconfigured':\n return { status: 503, error: 'Email isn’t set up here, so the quote wasn’t sent.' }\n case 'rate-limited':\n return { status: 429, error: 'Too many emails are going out right now. Try again in a minute.' }\n case 'suppressed':\n case 'undeliverable':\n return { status: 409, error: 'This address can’t receive email, so the quote wasn’t sent. Copy the pay link and send it another way.' }\n case 'unverified-domain':\n return { status: 409, error: 'This site’s sending domain isn’t verified, so the quote wasn’t sent. Check Email settings.' }\n case 'suspended':\n return { status: 423, error: 'This site can’t send email right now, so the quote wasn’t sent.' }\n case 'held-for-review':\n return { status: 409, error: `The email was held for review, so it hasn’t gone out yet.${result.detail ? ` ${result.detail}` : ''}` }\n default:\n return { status: 502, error: 'The quote email could not be sent. Try again.' }\n }\n}\n\n/** The quote email, said and sent. */\nexport function quoteMailer(deps: QuoteMailerDeps = defaultQuoteMailerDeps()): QuoteMailer {\n return {\n configured: () => deps.configured(),\n suppression: (email) => deps.suppression(email),\n deliver: async (input) => {\n const { hostId, quoteId, order, host } = input\n const businessName = quoteBusinessName(host)\n const subject = input.subject || CommerceModel.quoteEmailDefaultSubject(order, businessName, quoteId)\n const [identity, theme] = await Promise.all([\n deps.sendingIdentity(hostId),\n deps.theme(hostId).catch(() => null),\n ])\n\n const content = quoteEmailContent({\n order,\n quoteId,\n businessName,\n currency: String(input.store?.['currency'] ?? 'USD'),\n subject,\n message: input.message,\n payUrl: input.payUrl,\n proofUrl: input.proofUrl,\n linkReplaced: input.linkReplaced,\n colors: proofColors(theme),\n radius: theme?.buttonRadius,\n })\n\n // The PDF is what the email says is attached; without it nothing goes.\n let pdf: CommercePdfFile\n try {\n pdf = await deps.quotePdf({\n hostId,\n quoteId,\n order,\n host,\n store: input.store,\n owner: input.owner,\n payUrl: input.payUrl,\n })\n } catch (error) {\n console.error('[commerce/quote-email] the PDF could not be made', { hostId, quoteId, error })\n return { sent: false, status: 500, error: 'The quote’s PDF could not be made, so nothing was sent. Try again.' }\n }\n\n const rendered = await deps.render(hostId, QUOTE_EMAIL_KEY, content.tokens)\n const thread = readEmailThread(order.quote?.emailThread)\n const siteHost = String(host['cname'] ?? '').trim() || null\n const messageId = mintEmailMessageId([identity?.domain, identity?.from, siteHost], {\n label: 'quote',\n atMs: input.atMs,\n })\n const replyTo = quoteReplyTo(host, input.senderEmail)\n\n const result = await deps.send({\n to: input.to,\n // The subject as sent, whatever a design's own says: it is what keeps\n // the conversation together, and it was typed for this send.\n subject,\n text: rendered?.text || content.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n headers: nextThreadHeaders(thread, messageId),\n ...(replyTo ? { replyTo } : {}),\n fromName: resolveBrandingProfile(input.owner?.org as never).fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'quote',\n attachments: [\n {\n filename: pdf.filename,\n contentType: 'application/pdf',\n content: Buffer.from(pdf.bytes).toString('base64'),\n },\n ],\n })\n if (!result.sent) return { sent: false, ...refusalOf(result as Extract<SendEmailResult, { sent: false }>) }\n // Cost meter (AGL-1438), as every transactional site email.\n await deps.meter(hostId).catch((error) => console.error('[commerce/quote-email] not metered', error))\n return {\n sent: true,\n messageId,\n providerId: (result as Extract<SendEmailResult, { sent: true }>).id ?? null,\n subject,\n replyTo,\n thread: emailThreadAfterSend(thread, { messageId, subject }),\n }\n },\n }\n}\n"],"names":["siteContactEmail","resolveHostToken","resolveBrandingProfile","emailThreadAfterSend","isEmailConfigured","mintEmailMessageId","nextThreadHeaders","PLATFORM_EMAIL_PALETTE","readEmailThread","resolveEmailColor","sendEmail","firebaseAdmin","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","siteEmailFrame","transactionalAddressSuppression","CommerceModel","QUOTE_EMAIL_KEY","quoteEmailContent","defaultQuoteMailerDeps","firestore","app","configured","sendingIdentity","hostId","render","key","tokens","theme","quotePdf","input","quotePdfFile","send","options","meter","suppression","email","EMAIL","quoteBusinessName","host","named","String","trim","quoteReplyTo","senderEmail","contact","test","sender","proofColors","read","token","palette","border","muted","refusalOf","result","reason","status","error","detail","quoteMailer","deps","deliver","order","quoteId","businessName","subject","quoteEmailDefaultSubject","identity","Promise","all","catch","content","currency","store","message","payUrl","proofUrl","linkReplaced","colors","radius","buttonRadius","pdf","owner","console","sent","rendered","thread","quote","emailThread","siteHost","messageId","domain","from","label","atMs","replyTo","to","text","html","headers","fromName","org","audience","context","attachments","filename","contentType","Buffer","bytes","toString","providerId","id"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-email.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { siteContactEmail, resolveHostToken, type HostTokenSource } from '@aglyn/aglyn/app-utils/host-tokens'\nimport { resolveBrandingProfile } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n emailThreadAfterSend,\n isEmailConfigured,\n mintEmailMessageId,\n nextThreadHeaders,\n PLATFORM_EMAIL_PALETTE,\n readEmailThread,\n resolveEmailColor,\n sendEmail,\n type EmailTheme,\n type RenderedHostEmail,\n type SendEmailOptions,\n type SendEmailResult,\n type SendingIdentityVerdict,\n} from '@aglyn/shared-util-email'\nimport {\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n siteEmailFrame,\n transactionalAddressSuppression,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { QUOTE_EMAIL_KEY, quoteEmailContent, type QuoteEmailColors } from '../utils/quote-email-content'\nimport type { quotePdfFile } from './commerce-pdf'\nimport type { CommercePdfFile } from './pdf-response'\n\n/*==========================================\n * SENDING THE QUOTE EMAIL (AGL-4097). Loaded only by the console's Send\n * quote (`POST commerce/quote { action: 'send' }`), so the renderer, the PDF\n * writer and sharp stay out of every other request.\n *\n * One email, the `quote-sent` catalog key in the site's theme and its header\n * and footer (or the site's own design of it), with the quote's PDF attached:\n *\n * - FROM the site's sending identity, under the site's name;\n * - REPLY-TO the site's contact email (its listing's, else its support\n * email), else the email of the person sending, so a reply reaches the\n * business either way, as an inbox reply's does;\n * - IN THE CUSTOMER'S CONVERSATION: a `Message-ID` minted on the sending\n * domain, and, when the quote already has a conversation (an earlier\n * send, or a request's confirmation), `In-Reply-To` and `References`\n * from it, else the root of the one it starts in `References`\n * (`nextThreadHeaders`). The provider's own id is returned beside ours,\n * because a provider may replace a caller's `Message-ID` (in production\n * one does), and a replaced one can then be traced;\n * - METERED as a site's transactional email;\n * - REFUSED only for an address that bounced for good or reported mail as\n * spam (`transactionalAddressSuppression`): the customer asked for the\n * quote, so an unsubscribe from the site's newsletter does not stop it,\n * as it does not stop a receipt (AGL-4098).\n *\n * The route decides everything about the quote (who may send, the link, the\n * stage); this module only says the email and sends it.\n *=========================================*/\n\n/** What the route hands over for one send. */\nexport interface QuoteDeliveryInput {\n hostId: string\n quoteId: string\n /** The quote as it stands, after any pay link replacement this send made. */\n order: CommerceModel.HostOrder\n host: Record<string, unknown>\n store: Record<string, unknown> | null\n owner: { id?: string; org?: unknown } | null\n to: string\n /** The subject as typed; empty takes the default. */\n subject: string\n message: string\n payUrl: string\n proofUrl: (proofId: string) => string | null\n linkReplaced: boolean\n /** The sending member's email: the Reply-To when the site has no contact email. */\n senderEmail: string | null\n atMs: number\n}\n\nexport type QuoteDeliveryResult =\n | {\n sent: true\n messageId: string\n providerId: string | null\n subject: string\n replyTo: string | null\n thread: CommerceModel.QuoteEmailThread\n }\n | { sent: false; status: number; error: string }\n\n/** The calls the send makes, injectable for specs. */\nexport interface QuoteMailerDeps {\n configured(): boolean\n sendingIdentity(hostId: string): Promise<SendingIdentityVerdict>\n render(hostId: string, key: string, tokens: Record<string, string>): Promise<RenderedHostEmail | null>\n /** The site's email theme, for the colors the composed proof markup draws with. */\n theme(hostId: string): Promise<EmailTheme | null>\n /** The quote's PDF; loads the PDF writer and sharp only here. */\n quotePdf(input: Parameters<typeof quotePdfFile>[0]): Promise<CommercePdfFile>\n send(options: SendEmailOptions): Promise<SendEmailResult>\n meter(hostId: string): Promise<void>\n /** The bounce and complaint list only: a quote is mail the customer asked for. */\n suppression(email: string): Promise<'suppressed-platform' | null>\n}\n\nexport function defaultQuoteMailerDeps(): QuoteMailerDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n configured: () => isEmailConfigured(),\n sendingIdentity: (hostId) => hostSendingIdentity(hostId),\n render: (hostId, key, tokens) => renderHostEmailWithTokens(firestore(), hostId, key, tokens),\n theme: async (hostId) => (await siteEmailFrame(firestore(), hostId, QUOTE_EMAIL_KEY)).theme,\n quotePdf: async (input) => (await import('./commerce-pdf')).quotePdfFile(input),\n send: (options) => sendEmail(options),\n meter: async (hostId) => {\n await meterHostEmail(hostId)\n },\n suppression: (email) => transactionalAddressSuppression(email),\n }\n}\n\n/** What the console's Send quote reaches: whether mail can go, whether this address may be mailed, and the send. */\nexport interface QuoteMailer {\n configured(): boolean\n suppression(email: string): Promise<'suppressed-platform' | null>\n deliver(input: QuoteDeliveryInput): Promise<QuoteDeliveryResult>\n}\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/** The business's name as its documents print it. */\nexport function quoteBusinessName(host: Record<string, unknown>): string {\n const named = resolveHostToken('businessName', host as HostTokenSource)\n return String(named ?? '').trim() || String(host['name'] ?? '').trim()\n}\n\n/**\n * Who a reply reaches: the site's contact email, else the person sending, as\n * an inbox reply answers to them. Null only when neither is an address.\n */\nexport function quoteReplyTo(host: Record<string, unknown>, senderEmail: string | null | undefined): string | null {\n const contact = siteContactEmail(host as never)\n if (contact && EMAIL.test(contact)) return contact\n const sender = String(senderEmail ?? '').trim()\n return EMAIL.test(sender) ? sender : null\n}\n\n/** The theme's colors for the composed proof markup, the platform's where the site's cannot answer. */\nfunction proofColors(theme: EmailTheme | null): QuoteEmailColors {\n const read = (token: string) =>\n resolveEmailColor(token, theme?.palette) ?? resolveEmailColor(token, PLATFORM_EMAIL_PALETTE) ?? 'inherit'\n return { border: read('divider'), muted: read('text.secondary') }\n}\n\n/** A refused send in the words the console shows. */\nfunction refusalOf(result: Extract<SendEmailResult, { sent: false }>): { status: number; error: string } {\n switch (result.reason) {\n case 'unconfigured':\n return { status: 503, error: 'Email isn’t set up here, so the quote wasn’t sent.' }\n case 'rate-limited':\n return { status: 429, error: 'Too many emails are going out right now. Try again in a minute.' }\n case 'suppressed':\n case 'undeliverable':\n return { status: 409, error: 'This address can’t receive email, so the quote wasn’t sent. Copy the pay link and send it another way.' }\n case 'unverified-domain':\n return { status: 409, error: 'This site’s sending domain isn’t verified, so the quote wasn’t sent. Check Email settings.' }\n case 'suspended':\n return { status: 423, error: 'This site can’t send email right now, so the quote wasn’t sent.' }\n case 'held-for-review':\n return { status: 409, error: `The email was held for review, so it hasn’t gone out yet.${result.detail ? ` ${result.detail}` : ''}` }\n default:\n return { status: 502, error: 'The quote email could not be sent. Try again.' }\n }\n}\n\n/** The quote email, said and sent. */\nexport function quoteMailer(deps: QuoteMailerDeps = defaultQuoteMailerDeps()): QuoteMailer {\n return {\n configured: () => deps.configured(),\n suppression: (email) => deps.suppression(email),\n deliver: async (input) => {\n const { hostId, quoteId, order, host } = input\n const businessName = quoteBusinessName(host)\n const subject = input.subject || CommerceModel.quoteEmailDefaultSubject(order, businessName, quoteId)\n const [identity, theme] = await Promise.all([\n deps.sendingIdentity(hostId),\n deps.theme(hostId).catch(() => null),\n ])\n\n const content = quoteEmailContent({\n order,\n quoteId,\n businessName,\n currency: String(input.store?.['currency'] ?? 'USD'),\n subject,\n message: input.message,\n payUrl: input.payUrl,\n proofUrl: input.proofUrl,\n linkReplaced: input.linkReplaced,\n colors: proofColors(theme),\n radius: theme?.buttonRadius,\n })\n\n // The PDF is what the email says is attached; without it nothing goes.\n let pdf: CommercePdfFile\n try {\n pdf = await deps.quotePdf({\n hostId,\n quoteId,\n order,\n host,\n store: input.store,\n owner: input.owner,\n payUrl: input.payUrl,\n })\n } catch (error) {\n console.error('[commerce/quote-email] the PDF could not be made', { hostId, quoteId, error })\n return { sent: false, status: 500, error: 'The quote’s PDF could not be made, so nothing was sent. Try again.' }\n }\n\n const rendered = await deps.render(hostId, QUOTE_EMAIL_KEY, content.tokens)\n const thread = readEmailThread(order.quote?.emailThread)\n const siteHost = String(host['cname'] ?? '').trim() || null\n const messageId = mintEmailMessageId([identity?.domain, identity?.from, siteHost], {\n label: 'quote',\n atMs: input.atMs,\n })\n const replyTo = quoteReplyTo(host, input.senderEmail)\n\n const result = await deps.send({\n to: input.to,\n // The subject as sent, whatever a design's own says: it is what keeps\n // the conversation together, and it was typed for this send.\n subject,\n text: rendered?.text || content.text,\n ...(rendered?.html ? { html: rendered.html } : {}),\n headers: nextThreadHeaders(thread, messageId),\n ...(replyTo ? { replyTo } : {}),\n fromName: resolveBrandingProfile(input.owner?.org as never).fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'quote',\n attachments: [\n {\n filename: pdf.filename,\n contentType: 'application/pdf',\n content: Buffer.from(pdf.bytes).toString('base64'),\n },\n ],\n })\n if (!result.sent) return { sent: false, ...refusalOf(result as Extract<SendEmailResult, { sent: false }>) }\n // Cost meter (AGL-1438), as every transactional site email.\n await deps.meter(hostId).catch((error) => console.error('[commerce/quote-email] not metered', error))\n return {\n sent: true,\n messageId,\n providerId: (result as Extract<SendEmailResult, { sent: true }>).id ?? null,\n subject,\n replyTo,\n thread: emailThreadAfterSend(thread, { messageId, subject }),\n }\n },\n }\n}\n"],"names":["siteContactEmail","resolveHostToken","resolveBrandingProfile","emailThreadAfterSend","isEmailConfigured","mintEmailMessageId","nextThreadHeaders","PLATFORM_EMAIL_PALETTE","readEmailThread","resolveEmailColor","sendEmail","firebaseAdmin","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","siteEmailFrame","transactionalAddressSuppression","CommerceModel","QUOTE_EMAIL_KEY","quoteEmailContent","defaultQuoteMailerDeps","firestore","app","configured","sendingIdentity","hostId","render","key","tokens","theme","quotePdf","input","quotePdfFile","send","options","meter","suppression","email","EMAIL","quoteBusinessName","host","named","String","trim","quoteReplyTo","senderEmail","contact","test","sender","proofColors","read","token","palette","border","muted","refusalOf","result","reason","status","error","detail","quoteMailer","deps","deliver","order","quoteId","businessName","subject","quoteEmailDefaultSubject","identity","Promise","all","catch","content","currency","store","message","payUrl","proofUrl","linkReplaced","colors","radius","buttonRadius","pdf","owner","console","sent","rendered","thread","quote","emailThread","siteHost","messageId","domain","from","label","atMs","replyTo","to","text","html","headers","fromName","org","audience","context","attachments","filename","contentType","Buffer","bytes","toString","providerId","id"],"mappings":"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"}
@@ -41,8 +41,10 @@ export declare function quotePayLinkUrl(host: {
41
41
  * The absolute address of one of the current revision's proofs, behind the
42
42
  * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
43
43
  * or null when the site has no public address or no signing secret. What the
44
- * quote email draws a proof image from (AGL-4097); a link the business
45
- * replaces stops serving it, with the page.
44
+ * quote email draws a proof image from (AGL-4097). The email is a record the
45
+ * customer keeps, so the image keeps showing after the business starts the
46
+ * next revision or replaces the link (the page itself then says it was
47
+ * updated); it stops only if the business takes that proof off its revision.
46
48
  */
47
49
  export declare function quoteProofLinkUrl(host: {
48
50
  cname?: unknown;
@@ -97,8 +97,10 @@ function signQuoteLink(hostId, quoteId, version) {
97
97
  * The absolute address of one of the current revision's proofs, behind the
98
98
  * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),
99
99
  * or null when the site has no public address or no signing secret. What the
100
- * quote email draws a proof image from (AGL-4097); a link the business
101
- * replaces stops serving it, with the page.
100
+ * quote email draws a proof image from (AGL-4097). The email is a record the
101
+ * customer keeps, so the image keeps showing after the business starts the
102
+ * next revision or replaces the link (the page itself then says it was
103
+ * updated); it stops only if the business takes that proof off its revision.
102
104
  */ export function quoteProofLinkUrl(host, hostId, quoteId, version, proofId) {
103
105
  const origin = hostPublicOrigin({
104
106
  cname: typeof (host == null ? void 0 : host.cname) === 'string' ? host.cname : null,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n\n/**\n * The absolute address of one of the current revision's proofs, behind the\n * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),\n * or null when the site has no public address or no signing secret. What the\n * quote email draws a proof image from (AGL-4097); a link the business\n * replaces stops serving it, with the page.\n */\nexport function quoteProofLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n proofId: string,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token || !proofId) return null\n const query = new URLSearchParams({ hostId, q: quoteId, t: token, file: proofId })\n return `${origin}/api/commerce/quote-proof?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","createHmac","timingSafeEqual","quotePageHref","tokenSigningSecret","SIGNATURE_LENGTH","MAX_VERSION","signQuoteLink","hostId","quoteId","version","update","digest","slice","parseQuoteLinkToken","token","match","exec","String","Number","isSafeInteger","signature","mintQuoteLinkToken","verifyQuoteLinkToken","parsed","expected","a","Buffer","from","b","length","Uint8Array","quotePayLinkUrl","host","origin","cname","subdomain","quoteProofLinkUrl","proofId","query","URLSearchParams","q","t","file","toString"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/quote-link-token.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { hostPublicOrigin } from '@aglyn/aglyn/server'\nimport { createHmac, timingSafeEqual } from 'crypto'\nimport { quotePageHref } from '../constants/quote-page'\nimport { tokenSigningSecret } from './download'\n\n/**\n * A quote's pay link (AGL-4094): `/pay-quote?q=<quoteId>&t=<token>` on the\n * business's own site, where the customer sees the quote and pays it.\n *\n * The token is `<version>.<signature>`: the quote's link version\n * (`quote.linkVersion`, absent reading as 1) and the first 32 hex characters of\n * `HMAC(quote-link:hostId:quoteId:version)` under the commerce\n * `TOKEN_SIGNING_SECRET`. A pure function of what the quote already holds, so\n * nothing is stored per link and every message that carried the link carries\n * the same working one. The `quote-link:` prefix keeps it from being replayed\n * as an order-status, download or supplier token, which sign other payloads\n * under the same secret.\n *\n * NO EXPIRY. A quote is paid when the customer is ready, and a link that died\n * first would be a quote they could not pay. The version is what retires a\n * link: replacing it bumps the version on the quote, and a link signed for an\n * older version still verifies, so its page can say the quote was replaced,\n * without showing any of it.\n */\nconst SIGNATURE_LENGTH = 32\nconst MAX_VERSION = 1_000_000\n\nfunction signQuoteLink(hostId: string, quoteId: string, version: number): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`quote-link:${hostId}:${quoteId}:${version}`)\n .digest('hex')\n .slice(0, SIGNATURE_LENGTH)\n}\n\n/** The token's two halves, or null for a token not shaped like one. */\nexport function parseQuoteLinkToken(token: string): { version: number; signature: string } | null {\n const match = /^([1-9]\\d{0,6})\\.([0-9a-f]{32})$/.exec(String(token ?? ''))\n if (!match) return null\n const version = Number(match[1])\n if (!Number.isSafeInteger(version) || version > MAX_VERSION) return null\n return { version, signature: match[2] }\n}\n\n/** The token for one version of a quote's link, or null when the signing secret is unset. */\nexport function mintQuoteLinkToken(hostId: string, quoteId: string, version: number): string | null {\n if (!hostId || !quoteId || !Number.isSafeInteger(version) || version < 1 || version > MAX_VERSION) {\n return null\n }\n try {\n return `${version}.${signQuoteLink(hostId, quoteId, version)}`\n } catch {\n return null\n }\n}\n\n/**\n * Checks a presented token in constant time and answers the link version it\n * was signed for, or null for a token that is wrong, malformed or unverifiable\n * (no secret). The caller compares the version with the quote's own.\n */\nexport function verifyQuoteLinkToken(hostId: string, quoteId: string, token: string): number | null {\n if (!hostId || !quoteId) return null\n const parsed = parseQuoteLinkToken(token)\n if (!parsed) return null\n let expected: string\n try {\n expected = signQuoteLink(hostId, quoteId, parsed.version)\n } catch {\n return null\n }\n const a = Buffer.from(parsed.signature)\n const b = Buffer.from(expected)\n if (a.length !== b.length) return null\n return timingSafeEqual(new Uint8Array(a), new Uint8Array(b)) ? parsed.version : null\n}\n\n/**\n * The absolute pay link for a quote at a link version, or null when either\n * half is missing: a site with no public address, or no signing secret. What\n * the console copies, and what a quote or invoice email puts in front of the\n * customer.\n */\nexport function quotePayLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token) return null\n return `${origin}${quotePageHref(quoteId, token)}`\n}\n\n/**\n * The absolute address of one of the current revision's proofs, behind the\n * same signed link as the page (`GET /api/commerce/quote-proof`, AGL-4096),\n * or null when the site has no public address or no signing secret. What the\n * quote email draws a proof image from (AGL-4097). The email is a record the\n * customer keeps, so the image keeps showing after the business starts the\n * next revision or replaces the link (the page itself then says it was\n * updated); it stops only if the business takes that proof off its revision.\n */\nexport function quoteProofLinkUrl(\n host: { cname?: unknown; subdomain?: unknown } | null | undefined,\n hostId: string,\n quoteId: string,\n version: number,\n proofId: string,\n): string | null {\n const origin = hostPublicOrigin({\n cname: typeof host?.cname === 'string' ? host.cname : null,\n subdomain: typeof host?.subdomain === 'string' ? host.subdomain : null,\n })\n const token = mintQuoteLinkToken(hostId, quoteId, version)\n if (!origin || !token || !proofId) return null\n const query = new URLSearchParams({ hostId, q: quoteId, t: token, file: proofId })\n return `${origin}/api/commerce/quote-proof?${query.toString()}`\n}\n"],"names":["hostPublicOrigin","createHmac","timingSafeEqual","quotePageHref","tokenSigningSecret","SIGNATURE_LENGTH","MAX_VERSION","signQuoteLink","hostId","quoteId","version","update","digest","slice","parseQuoteLinkToken","token","match","exec","String","Number","isSafeInteger","signature","mintQuoteLinkToken","verifyQuoteLinkToken","parsed","expected","a","Buffer","from","b","length","Uint8Array","quotePayLinkUrl","host","origin","cname","subdomain","quoteProofLinkUrl","proofId","query","URLSearchParams","q","t","file","toString"],"mappings":"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"}
@@ -75,10 +75,14 @@ export interface LoadedQuote {
75
75
  /**
76
76
  * The quote a link names, after its token: the quote, the replaced answer for
77
77
  * a link to an older version, or the one not-found answer for everything else.
78
- * With `invoices`, a draft order's invoice too (AGL-4099).
78
+ * With `invoices`, a draft order's invoice too (AGL-4099). With `history`, a
79
+ * link to an older version opens the quote as well: only for a route that
80
+ * serves the quote's record and acts on nothing (its proofs, which an earlier
81
+ * email draws, AGL-4097); pay, answers and uploads keep the current version.
79
82
  */
80
83
  export declare function loadLinkedQuote(deps: QuotePayDeps, hostId: string, quoteId: string, token: string, options?: {
81
84
  invoices?: boolean;
85
+ history?: boolean;
82
86
  }): Promise<{
83
87
  loaded: LoadedQuote;
84
88
  } | {
@@ -155,7 +155,10 @@ function headerOf(req, name) {
155
155
  /**
156
156
  * The quote a link names, after its token: the quote, the replaced answer for
157
157
  * a link to an older version, or the one not-found answer for everything else.
158
- * With `invoices`, a draft order's invoice too (AGL-4099).
158
+ * With `invoices`, a draft order's invoice too (AGL-4099). With `history`, a
159
+ * link to an older version opens the quote as well: only for a route that
160
+ * serves the quote's record and acts on nothing (its proofs, which an earlier
161
+ * email draws, AGL-4097); pay, answers and uploads keep the current version.
159
162
  */ export async function loadLinkedQuote(deps, hostId, quoteId, token, options = {}) {
160
163
  var _hostSnapshot_data, _resolveHostToken;
161
164
  const notFound = options.invoices ? NOT_FOUND_ANY : NOT_FOUND;
@@ -183,7 +186,7 @@ function headerOf(req, name) {
183
186
  if (version > current) return {
184
187
  outcome: notFound
185
188
  };
186
- if (version < current) {
189
+ if (version < current && !options.history) {
187
190
  return {
188
191
  outcome: {
189
192
  status: 410,