@aglyn/plugins-commerce 1.0.0-beta.296 → 1.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-commerce",
3
- "version": "1.0.0-beta.296",
3
+ "version": "1.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.296",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.296",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.296",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.296",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.296",
33
- "@aglyn/shared-util-email": "1.0.0-beta.296",
34
- "@aglyn/shared-util-http": "1.0.0-beta.296",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.296",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.296",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.296",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.296",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.296",
28
+ "@aglyn/aglyn": "1.1.0",
29
+ "@aglyn/shared-data-mdi": "1.1.0",
30
+ "@aglyn/shared-ui-jsx": "1.1.0",
31
+ "@aglyn/shared-ui-next": "1.1.0",
32
+ "@aglyn/shared-ui-snackstack": "1.1.0",
33
+ "@aglyn/shared-util-email": "1.1.0",
34
+ "@aglyn/shared-util-http": "1.1.0",
35
+ "@aglyn/shared-util-timestamp": "1.1.0",
36
+ "@aglyn/shared-util-tools": "1.1.0",
37
+ "@aglyn/tenant-data-admin": "1.1.0",
38
+ "@aglyn/tenant-feature-instance": "1.1.0",
39
+ "@aglyn/tenant-runtime": "1.1.0",
40
40
  "@stripe/react-stripe-js": "^6.12.0",
41
41
  "@stripe/stripe-js": "^9.17.0",
42
42
  "@swc/helpers": "0.5.23",
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /** The published Terms version the summary and every acceptance name. */
18
- export declare const POS_HARDWARE_TERMS_VERSION = "v20";
18
+ export declare const POS_HARDWARE_TERMS_VERSION = "v21";
19
19
  /** The sections of the Terms that are the terms of sale for hardware. */
20
20
  export declare const POS_HARDWARE_TERMS_SECTIONS = "4.26\u20134.32";
21
21
  /** The published Terms, where the full text is. */
@@ -33,7 +33,7 @@
33
33
  * `POS_HARDWARE_TERMS_VERSION` is the Terms version whose Sections 4.26–4.32
34
34
  * the summary below follows. It moves only when those sections change; a
35
35
  * console spec holds it at or below the published version.
36
- *=========================================*/ /** The published Terms version the summary and every acceptance name. */ export const POS_HARDWARE_TERMS_VERSION = 'v20';
36
+ *=========================================*/ /** The published Terms version the summary and every acceptance name. */ export const POS_HARDWARE_TERMS_VERSION = 'v21';
37
37
  /** The sections of the Terms that are the terms of sale for hardware. */ export const POS_HARDWARE_TERMS_SECTIONS = '4.26–4.32';
38
38
  /** The published Terms, where the full text is. */ export function posHardwareTermsUrl() {
39
39
  return `${LEGAL_ORIGIN}/legal/terms`;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/pos-hardware-terms.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 { LEGAL_ORIGIN } from '@aglyn/aglyn/app-utils/published-legal-pages'\n\n/*==========================================\n * THE TERMS OF SALE AT THE BOOTH (AGL-4038).\n *\n * The platform sells card readers in person under its published Terms,\n * Sections 4.26 to 4.32. Section 4.28 says how a buyer agrees: before paying,\n * they accept those terms electronically — on the register tablet handed to\n * them, or on the register's customer display — and where that can't be done\n * (no power, no connection), the receipt and a sign at the booth state that\n * the sale is made under them, and paying is agreeing.\n *\n * So a register sale of hardware carries `hardwareTermsAcceptance`: which\n * version of the Terms, when, how (`tablet`, `display`, or `notice` for the\n * receipt-and-sign route), the register user who handed it over, and the\n * device's address and browser, as a signature's audit does. The hardware\n * order the sale becomes copies it (`termsAcceptance`).\n *\n * `POS_HARDWARE_TERMS_VERSION` is the Terms version whose Sections 4.26–4.32\n * the summary below follows. It moves only when those sections change; a\n * console spec holds it at or below the published version.\n *=========================================*/\n\n/** The published Terms version the summary and every acceptance name. */\nexport const POS_HARDWARE_TERMS_VERSION = 'v20'\n\n/** The sections of the Terms that are the terms of sale for hardware. */\nexport const POS_HARDWARE_TERMS_SECTIONS = '4.26–4.32'\n\n/** The published Terms, where the full text is. */\nexport function posHardwareTermsUrl(): string {\n return `${LEGAL_ORIGIN}/legal/terms`\n}\n\n/** The Terms' address as a receipt or a sign prints it: `example.com/legal/terms`. */\nexport function posHardwareTermsAddress(): string {\n try {\n return `${new URL(LEGAL_ORIGIN).host}/legal/terms`\n } catch {\n return '/legal/terms'\n }\n}\n\n/**\n * What the buyer reads before accepting: Sections 4.26–4.32 in short, in the\n * published text's own order and terms, with the link to the whole. Never\n * instead of it: the acceptance is of the Terms, which this summarizes.\n */\nexport const POS_HARDWARE_TERMS_SUMMARY: readonly string[] = [\n 'We are the seller of this hardware. It is for use in a business in the United States, not for personal, family or household use, and you may not resell it.',\n 'Ownership and the risk of loss pass to you when the hardware is handed to you. Sales tax is charged at the rate our register charges for this sale.',\n 'You may return it within 30 days of buying it, in its original packaging and condition with all its accessories. You pay the return shipping, and any restocking fee Stripe charges us for the return comes off the refund.',\n 'For one year we warrant it, other than cables and other accessories, against defects in materials and workmanship under normal use. Repair or replacement is the only remedy.',\n 'Its software is Stripe’s, under Stripe’s terms. It needs an internet connection you provide, and its price does not include any internet or cellular data service.',\n 'Apart from that warranty, the hardware is provided as is, and our liability for it is limited to the amount you paid for it.',\n]\n\n/** How a buyer agreed to the terms of sale. */\nexport type PosHardwareTermsMethod = 'tablet' | 'display' | 'notice'\n\n/** `hardwareTermsAcceptance` on a register sale, and `termsAcceptance` on the hardware order. */\nexport interface PosHardwareTermsAcceptance {\n version: string\n sections: string\n method: PosHardwareTermsMethod\n atMs: number\n /** The register user who handed the tablet over or put the terms on the display; null for a notice. */\n staffUid: string | null\n /** The address and browser of the device the buyer accepted on; null for a notice. */\n ip: string | null\n userAgent: string | null\n}\n\nconst METHODS: readonly PosHardwareTermsMethod[] = ['tablet', 'display', 'notice']\n\n/** An acceptance as stored, or null for anything that is not one. */\nexport function posHardwareTermsAcceptanceOf(raw: unknown): PosHardwareTermsAcceptance | null {\n if (!raw || typeof raw !== 'object') return null\n const value = raw as Record<string, unknown>\n const method = String(value['method'] ?? '') as PosHardwareTermsMethod\n const atMs = Number(value['atMs'])\n if (!METHODS.includes(method) || !Number.isFinite(atMs) || atMs <= 0) return null\n const text = (field: string, max: number) =>\n typeof value[field] === 'string' && (value[field] as string) ? (value[field] as string).slice(0, max) : null\n return {\n version: text('version', 20) ?? POS_HARDWARE_TERMS_VERSION,\n sections: text('sections', 40) ?? POS_HARDWARE_TERMS_SECTIONS,\n method,\n atMs,\n staffUid: text('staffUid', 128),\n ip: text('ip', 100),\n userAgent: text('userAgent', 400),\n }\n}\n\n/** A fresh acceptance of the current terms of sale. */\nexport function posHardwareTermsAcceptance(input: {\n method: PosHardwareTermsMethod\n atMs: number\n staffUid?: string | null\n ip?: string | null\n userAgent?: string | null\n}): PosHardwareTermsAcceptance {\n return {\n version: POS_HARDWARE_TERMS_VERSION,\n sections: POS_HARDWARE_TERMS_SECTIONS,\n method: input.method,\n atMs: input.atMs,\n staffUid: input.method === 'notice' ? null : (input.staffUid ?? null),\n ip: input.method === 'notice' ? null : (input.ip ?? null)?.slice(0, 100) ?? null,\n userAgent: input.method === 'notice' ? null : (input.userAgent ?? null)?.slice(0, 400) ?? null,\n }\n}\n\n/** Whether a register sale sells hardware: marked when rung, or recorded since. */\nexport function isPosHardwareRegisterSale(order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined): boolean {\n return order?.hardwareSalePending === true || (typeof order?.hardwareOrderId === 'string' && Boolean(order.hardwareOrderId))\n}\n\n/**\n * The line every receipt of a register sale of hardware prints (Section\n * 4.28's fallback, printed whether or not the buyer also accepted\n * electronically): which terms the sale is made under, and that paying is\n * agreeing to them. Null on every other sale.\n */\nexport function posHardwareReceiptNotice(\n order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined,\n): string | null {\n if (!isPosHardwareRegisterSale(order)) return null\n return (\n `Card reader hardware on this receipt is sold under ${posHardwareTermsAddress()}, ` +\n `Sections ${POS_HARDWARE_TERMS_SECTIONS}. By paying for it you agree to those terms for this purchase.`\n )\n}\n\n/** A receipt footer with the terms notice after it, for a sale of hardware. */\nexport function withPosHardwareReceiptNotice(\n footer: string | null | undefined,\n order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined,\n): string {\n return [String(footer ?? '').trim(), posHardwareReceiptNotice(order)].filter(Boolean).join('\\n')\n}\n"],"names":["LEGAL_ORIGIN","POS_HARDWARE_TERMS_VERSION","POS_HARDWARE_TERMS_SECTIONS","posHardwareTermsUrl","posHardwareTermsAddress","URL","host","POS_HARDWARE_TERMS_SUMMARY","METHODS","posHardwareTermsAcceptanceOf","raw","value","text","method","String","atMs","Number","includes","isFinite","field","max","slice","version","sections","staffUid","ip","userAgent","posHardwareTermsAcceptance","input","isPosHardwareRegisterSale","order","hardwareSalePending","hardwareOrderId","Boolean","posHardwareReceiptNotice","withPosHardwareReceiptNotice","footer","trim","filter","join"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/model/pos-hardware-terms.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 { LEGAL_ORIGIN } from '@aglyn/aglyn/app-utils/published-legal-pages'\n\n/*==========================================\n * THE TERMS OF SALE AT THE BOOTH (AGL-4038).\n *\n * The platform sells card readers in person under its published Terms,\n * Sections 4.26 to 4.32. Section 4.28 says how a buyer agrees: before paying,\n * they accept those terms electronically — on the register tablet handed to\n * them, or on the register's customer display — and where that can't be done\n * (no power, no connection), the receipt and a sign at the booth state that\n * the sale is made under them, and paying is agreeing.\n *\n * So a register sale of hardware carries `hardwareTermsAcceptance`: which\n * version of the Terms, when, how (`tablet`, `display`, or `notice` for the\n * receipt-and-sign route), the register user who handed it over, and the\n * device's address and browser, as a signature's audit does. The hardware\n * order the sale becomes copies it (`termsAcceptance`).\n *\n * `POS_HARDWARE_TERMS_VERSION` is the Terms version whose Sections 4.26–4.32\n * the summary below follows. It moves only when those sections change; a\n * console spec holds it at or below the published version.\n *=========================================*/\n\n/** The published Terms version the summary and every acceptance name. */\nexport const POS_HARDWARE_TERMS_VERSION = 'v21'\n\n/** The sections of the Terms that are the terms of sale for hardware. */\nexport const POS_HARDWARE_TERMS_SECTIONS = '4.26–4.32'\n\n/** The published Terms, where the full text is. */\nexport function posHardwareTermsUrl(): string {\n return `${LEGAL_ORIGIN}/legal/terms`\n}\n\n/** The Terms' address as a receipt or a sign prints it: `example.com/legal/terms`. */\nexport function posHardwareTermsAddress(): string {\n try {\n return `${new URL(LEGAL_ORIGIN).host}/legal/terms`\n } catch {\n return '/legal/terms'\n }\n}\n\n/**\n * What the buyer reads before accepting: Sections 4.26–4.32 in short, in the\n * published text's own order and terms, with the link to the whole. Never\n * instead of it: the acceptance is of the Terms, which this summarizes.\n */\nexport const POS_HARDWARE_TERMS_SUMMARY: readonly string[] = [\n 'We are the seller of this hardware. It is for use in a business in the United States, not for personal, family or household use, and you may not resell it.',\n 'Ownership and the risk of loss pass to you when the hardware is handed to you. Sales tax is charged at the rate our register charges for this sale.',\n 'You may return it within 30 days of buying it, in its original packaging and condition with all its accessories. You pay the return shipping, and any restocking fee Stripe charges us for the return comes off the refund.',\n 'For one year we warrant it, other than cables and other accessories, against defects in materials and workmanship under normal use. Repair or replacement is the only remedy.',\n 'Its software is Stripe’s, under Stripe’s terms. It needs an internet connection you provide, and its price does not include any internet or cellular data service.',\n 'Apart from that warranty, the hardware is provided as is, and our liability for it is limited to the amount you paid for it.',\n]\n\n/** How a buyer agreed to the terms of sale. */\nexport type PosHardwareTermsMethod = 'tablet' | 'display' | 'notice'\n\n/** `hardwareTermsAcceptance` on a register sale, and `termsAcceptance` on the hardware order. */\nexport interface PosHardwareTermsAcceptance {\n version: string\n sections: string\n method: PosHardwareTermsMethod\n atMs: number\n /** The register user who handed the tablet over or put the terms on the display; null for a notice. */\n staffUid: string | null\n /** The address and browser of the device the buyer accepted on; null for a notice. */\n ip: string | null\n userAgent: string | null\n}\n\nconst METHODS: readonly PosHardwareTermsMethod[] = ['tablet', 'display', 'notice']\n\n/** An acceptance as stored, or null for anything that is not one. */\nexport function posHardwareTermsAcceptanceOf(raw: unknown): PosHardwareTermsAcceptance | null {\n if (!raw || typeof raw !== 'object') return null\n const value = raw as Record<string, unknown>\n const method = String(value['method'] ?? '') as PosHardwareTermsMethod\n const atMs = Number(value['atMs'])\n if (!METHODS.includes(method) || !Number.isFinite(atMs) || atMs <= 0) return null\n const text = (field: string, max: number) =>\n typeof value[field] === 'string' && (value[field] as string) ? (value[field] as string).slice(0, max) : null\n return {\n version: text('version', 20) ?? POS_HARDWARE_TERMS_VERSION,\n sections: text('sections', 40) ?? POS_HARDWARE_TERMS_SECTIONS,\n method,\n atMs,\n staffUid: text('staffUid', 128),\n ip: text('ip', 100),\n userAgent: text('userAgent', 400),\n }\n}\n\n/** A fresh acceptance of the current terms of sale. */\nexport function posHardwareTermsAcceptance(input: {\n method: PosHardwareTermsMethod\n atMs: number\n staffUid?: string | null\n ip?: string | null\n userAgent?: string | null\n}): PosHardwareTermsAcceptance {\n return {\n version: POS_HARDWARE_TERMS_VERSION,\n sections: POS_HARDWARE_TERMS_SECTIONS,\n method: input.method,\n atMs: input.atMs,\n staffUid: input.method === 'notice' ? null : (input.staffUid ?? null),\n ip: input.method === 'notice' ? null : (input.ip ?? null)?.slice(0, 100) ?? null,\n userAgent: input.method === 'notice' ? null : (input.userAgent ?? null)?.slice(0, 400) ?? null,\n }\n}\n\n/** Whether a register sale sells hardware: marked when rung, or recorded since. */\nexport function isPosHardwareRegisterSale(order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined): boolean {\n return order?.hardwareSalePending === true || (typeof order?.hardwareOrderId === 'string' && Boolean(order.hardwareOrderId))\n}\n\n/**\n * The line every receipt of a register sale of hardware prints (Section\n * 4.28's fallback, printed whether or not the buyer also accepted\n * electronically): which terms the sale is made under, and that paying is\n * agreeing to them. Null on every other sale.\n */\nexport function posHardwareReceiptNotice(\n order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined,\n): string | null {\n if (!isPosHardwareRegisterSale(order)) return null\n return (\n `Card reader hardware on this receipt is sold under ${posHardwareTermsAddress()}, ` +\n `Sections ${POS_HARDWARE_TERMS_SECTIONS}. By paying for it you agree to those terms for this purchase.`\n )\n}\n\n/** A receipt footer with the terms notice after it, for a sale of hardware. */\nexport function withPosHardwareReceiptNotice(\n footer: string | null | undefined,\n order: { hardwareSalePending?: unknown; hardwareOrderId?: unknown } | null | undefined,\n): string {\n return [String(footer ?? '').trim(), posHardwareReceiptNotice(order)].filter(Boolean).join('\\n')\n}\n"],"names":["LEGAL_ORIGIN","POS_HARDWARE_TERMS_VERSION","POS_HARDWARE_TERMS_SECTIONS","posHardwareTermsUrl","posHardwareTermsAddress","URL","host","POS_HARDWARE_TERMS_SUMMARY","METHODS","posHardwareTermsAcceptanceOf","raw","value","text","method","String","atMs","Number","includes","isFinite","field","max","slice","version","sections","staffUid","ip","userAgent","posHardwareTermsAcceptance","input","isPosHardwareRegisterSale","order","hardwareSalePending","hardwareOrderId","Boolean","posHardwareReceiptNotice","withPosHardwareReceiptNotice","footer","trim","filter","join"],"mappings":"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"}
@@ -19,7 +19,10 @@ import { type PluginApiHandler, type PluginJobHostGate } from '@aglyn/aglyn/serv
19
19
  export interface AbandonedScanResult {
20
20
  scanned: number;
21
21
  sent: number;
22
- /** Rows left untouched because their site is locked (AGL-2495). */
22
+ /**
23
+ * Rows owed an expiry or a reminder and left untouched because their site
24
+ * is locked (AGL-2495). A row owed nothing is not asked about (AGL-4121).
25
+ */
23
26
  skippedLocked: number;
24
27
  }
25
28
  /**
@@ -80,10 +80,21 @@ import { CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS, CHECKOUT_REMIND_AFTER_MS
80
80
  // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.
81
81
  const hostId = (_docSnapshot_ref_parent_parent = docSnapshot.ref.parent.parent) == null ? void 0 : _docSnapshot_ref_parent_parent.id;
82
82
  if (!hostId) continue;
83
- // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather
84
- // than sitting next to the email below, because the `status: 'expired'`
85
- // stamp further down is itself a write for this host. Reordered for that
86
- // reason: the cheap filters used to run first, and one of them wrote.
83
+ const createdAtMs = Number((_data_createdAtMs = data.createdAtMs) != null ? _data_createdAtMs : 0);
84
+ const pastGivingUp = now - createdAtMs > GIVE_UP_AFTER_MS;
85
+ // Rows nothing is owed on leave here, before the gate (AGL-4121): the
86
+ // verdict is three reads, and this window is mostly checkouts too young
87
+ // to remind or already reminded. Neither filter writes or mails, so
88
+ // neither needs a verdict. Past the give-up age a row IS owed a write, so
89
+ // it goes on to the gate whatever else it is.
90
+ if (!pastGivingUp) {
91
+ if (!data.email || data.remindedAtMs) continue;
92
+ if (now - createdAtMs < REMIND_AFTER_MS) continue;
93
+ }
94
+ // LOCKDOWN (AGL-2495), and it is ahead of everything below rather than
95
+ // sitting next to the email, because the `status: 'expired'` stamp is
96
+ // itself a write for this host. Only filters that write nothing may run
97
+ // before it.
87
98
  //
88
99
  // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and
89
100
  // unstamped, so the reminder is sent — or the row expired — on the first
@@ -93,14 +104,13 @@ import { CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS, CHECKOUT_REMIND_AFTER_MS
93
104
  skippedLocked += 1;
94
105
  continue;
95
106
  }
96
- const createdAtMs = Number((_data_createdAtMs = data.createdAtMs) != null ? _data_createdAtMs : 0);
97
- // Expiry comes BEFORE the email and reminded skips. This pass reads 200
98
- // open checkouts, and a checkout that never had an address, or was
99
- // already reminded, used to be skipped while still `open` — forever — so
100
- // enough of them would fill every pass and starve the ones still owed a
101
- // reminder. Past the give-up age every open checkout closes, whatever
107
+ // Expiry comes BEFORE the email and reminded skips have any say. This
108
+ // pass reads 200 open checkouts, and a checkout that never had an address,
109
+ // or was already reminded, used to be skipped while still `open` — forever
110
+ // — so enough of them would fill every pass and starve the ones still owed
111
+ // a reminder. Past the give-up age every open checkout closes, whatever
102
112
  // else it is.
103
- if (now - createdAtMs > GIVE_UP_AFTER_MS) {
113
+ if (pastGivingUp) {
104
114
  await docSnapshot.ref.set({
105
115
  status: 'expired'
106
116
  }, {
@@ -108,8 +118,6 @@ import { CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS, CHECKOUT_REMIND_AFTER_MS
108
118
  }).catch(()=>undefined);
109
119
  continue;
110
120
  }
111
- if (!data.email || data.remindedAtMs) continue;
112
- if (now - createdAtMs < REMIND_AFTER_MS) continue;
113
121
  if (!entitledHosts.has(hostId)) {
114
122
  var _ref1, _Aglyn_hostPublicOrigin;
115
123
  const org = await getOrgForHost(hostId).catch(()=>null);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.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 * as Aglyn from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /** Rows left untouched because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n // LOCKDOWN (AGL-2495), and it is the FIRST thing in the loop body rather\n // than sitting next to the email below, because the `status: 'expired'`\n // stamp further down is itself a write for this host. Reordered for that\n // reason: the cheap filters used to run first, and one of them wrote.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n const createdAtMs = Number(data.createdAtMs ?? 0)\n // Expiry comes BEFORE the email and reminded skips. This pass reads 200\n // open checkouts, and a checkout that never had an address, or was\n // already reminded, used to be skipped while still `open` — forever — so\n // enough of them would fill every pass and starve the ones still owed a\n // reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (now - createdAtMs > GIVE_UP_AFTER_MS) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'You left items in your cart. Pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n // Not \"held\" (AGL-3432): the checkout's stock hold expires after\n // 31 minutes, before this reminder goes out an hour in.\n 'Nothing in your cart is held for you.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.DEFAULT_SUBSCRIPTION_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","set","status","merge","catch","undefined","email","remindedAtMs","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","DEFAULT_SUBSCRIPTION_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-abandoned.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 * as Aglyn from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nimport {\n CHECKOUT_GIVE_UP_AFTER_MS as GIVE_UP_AFTER_MS,\n CHECKOUT_REMIND_AFTER_MS as REMIND_AFTER_MS,\n type CheckoutRecoveryState,\n} from '../model/checkout-recovery'\n\n/** One pass over the open checkouts. Bounded (200) and idempotent. */\nexport interface AbandonedScanResult {\n scanned: number\n sent: number\n /**\n * Rows owed an expiry or a reminder and left untouched because their site\n * is locked (AGL-2495). A row owed nothing is not asked about (AGL-4121).\n */\n skippedLocked: number\n}\n\n/**\n * The abandoned-checkout pass itself, separated from its HTTP door (AGL-2227).\n *\n * It was only ever reachable through `processAbandonedHandler`, which is\n * `x-cron-secret`-gated — and **nothing has ever POSTed to it**. It is not in\n * `.github/workflows/scheduled-crons.yml`, there is no `crons` key in\n * `vercel.json`, and it was not a `registerPluginJob`. `server.ts` described\n * these as \"the scheduler-driven jobs\" in a comment, which is how a dead\n * feature stays invisible: the code asserted the wiring rather than having it.\n *\n * So `abandonedCart` — a Pro-tier entitlement, sold on /pricing, with a\n * console row added by AGL-2081 telling the merchant their template needs it —\n * had never sent one email. Exporting the scan lets `server.ts` register it on\n * the platform job beat (the every-minute `pluginJobsBeat` that AGL-2176\n * proved out) while the HTTP door stays for manual/ops invocation and for the\n * console's \"send now\" control.\n */\nexport async function scanAbandonedCheckouts(\n /**\n * The lockdown gate, injected by whoever drives the pass (AGL-2495). NOT\n * optional: a default would make \"forgot to thread it\" compile, which is\n * the whole failure this parameter exists to make impossible.\n */\n gate: PluginJobHostGate,\n): Promise<AbandonedScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const now = Date.now()\n // Collection-group over every host's checkouts.\n const openCheckouts = await firestore\n .collectionGroup('checkouts')\n .where('status', '==', 'open')\n .limit(200)\n .get()\n let sent = 0\n const entitledHosts = new Map<string, boolean>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // alongside the entitlement, from the same org doc, through the one shared\n // resolver — so the recovery email's sender reads as the store's brand.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the reminder. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its entitlement, so\n * the gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n let skippedLocked = 0\n for (const docSnapshot of openCheckouts.docs) {\n const data = docSnapshot.data() as any\n // `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.\n const hostId = docSnapshot.ref.parent.parent?.id\n if (!hostId) continue\n const createdAtMs = Number(data.createdAtMs ?? 0)\n const pastGivingUp = now - createdAtMs > GIVE_UP_AFTER_MS\n // Rows nothing is owed on leave here, before the gate (AGL-4121): the\n // verdict is three reads, and this window is mostly checkouts too young\n // to remind or already reminded. Neither filter writes or mails, so\n // neither needs a verdict. Past the give-up age a row IS owed a write, so\n // it goes on to the gate whatever else it is.\n if (!pastGivingUp) {\n if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\n }\n // LOCKDOWN (AGL-2495), and it is ahead of everything below rather than\n // sitting next to the email, because the `status: 'expired'` stamp is\n // itself a write for this host. Only filters that write nothing may run\n // before it.\n //\n // SKIPPED, NOT DROPPED: `continue` leaves the checkout `open` and\n // unstamped, so the reminder is sent — or the row expired — on the first\n // beat after the lift. Nothing here marks a checkout done, so declining\n // to act IS leaving it for later.\n if (await gate.isLocked(hostId)) {\n skippedLocked += 1\n continue\n }\n // Expiry comes BEFORE the email and reminded skips have any say. This\n // pass reads 200 open checkouts, and a checkout that never had an address,\n // or was already reminded, used to be skipped while still `open` — forever\n // — so enough of them would fill every pass and starve the ones still owed\n // a reminder. Past the give-up age every open checkout closes, whatever\n // else it is.\n if (pastGivingUp) {\n await docSnapshot.ref\n .set({ status: 'expired' }, { merge: true })\n .catch(() => undefined)\n continue\n }\n if (!entitledHosts.has(hostId)) {\n const org = await getOrgForHost(hostId).catch(() => null)\n entitledHosts.set(\n hostId,\n Aglyn.checkEntitlement(org?.org as any, 'abandonedCart'),\n )\n brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org?.org as any))\n consentGroupByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host for the same reason the branding beside it is: this sweep is a\n // `collectionGroup` over every site's checkouts, and a read per\n // reminder would be a read per shopper.\n const hostSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null)\n siteBaseByHost.set(\n hostId,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n if (!entitledHosts.get(hostId)) continue\n let loaded = templateCache.get(hostId)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * precisely because it is resumable: a checkout left unstamped below is\n * re-scanned on the next beat, so a refusal means \"not this hour\" rather\n * than a reminder nobody ever gets.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostId) ?? Aglyn.soloConsentGroup(hostId)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'You left items in your cart. Pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n // Not \"held\" (AGL-3432): the checkout's stock hold expires after\n // 31 minutes, before this reminder goes out an hour in.\n 'Nothing in your cart is held for you.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostId)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostId, identityByHost),\n audience: 'tenant',\n context: 'abandoned cart',\n priority: 'bulk',\n /*\n * A cart reminder is promotional mail — the \"Promotions and offers\"\n * stream, which is also where a campaign lands by default. Declared so\n * a shopper who left that stream stops receiving these; the gate asks\n * the topic only for callers that name one.\n */\n marketing: {\n hostId,\n siteBase: siteBaseByHost.get(hostId) ?? '',\n topicId: Aglyn.DEFAULT_SUBSCRIPTION_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `remindedAtMs` is what retires a checkout from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would turn a\n * deferral into a reminder nobody ever gets; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row on every\n * beat until it crowded out the checkouts that could still be reminded.\n * `isDeferrableSendResult` is the one place that distinction is made.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One reminder per abandoned checkout, triggered\n // by that shopper's own action rather than composed as a broadcast, so\n // it counts toward cost without entering the campaign cap. On the\n // DELIVERED message only: a suppressed recipient produced no message, so\n // there is no cost to record.\n if (result.sent) await meterHostEmail(hostId)\n // `recoveryState` beside it, so the console's recovery queue counts this\n // checkout as reminded by query rather than by reading it (AGL-3321).\n const reminded: CheckoutRecoveryState = 'reminded'\n await docSnapshot.ref\n .set({ remindedAtMs: now, recoveryState: reminded }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: openCheckouts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-323). Kept `x-cron-secret`-gated so an\n * external scheduler or an operator can still drive it; the job registration\n * in `server.ts` is what makes it actually run.\n */\nexport const processAbandonedHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret) {\n return res.status(501).json({ error: 'Not configured (CRON_SECRET).' })\n }\n if (req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495): a\n // forced pass is still a pass, and `x-cron-secret` is not an argument\n // for mailing a suspended site's shoppers.\n return res.status(200).json(await scanAbandonedCheckouts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","CHECKOUT_GIVE_UP_AFTER_MS","GIVE_UP_AFTER_MS","CHECKOUT_REMIND_AFTER_MS","REMIND_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","createdAtMs","Number","pastGivingUp","email","remindedAtMs","isLocked","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","DEFAULT_SUBSCRIPTION_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","reminded","recoveryState","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -18,7 +18,10 @@ import { type PluginApiHandler, type PluginJobHostGate } from '@aglyn/aglyn/serv
18
18
  export interface RestockScanResult {
19
19
  scanned: number;
20
20
  sent: number;
21
- /** Alerts left unstamped because their site is locked (AGL-2495). */
21
+ /**
22
+ * Alerts owed a stamp or an email and left unstamped because their site is
23
+ * locked (AGL-2495). An alert owed nothing is not asked about (AGL-4121).
24
+ */
22
25
  skippedLocked: number;
23
26
  }
24
27
  /**
@@ -66,17 +66,6 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
66
66
  var _brandingByHost_get;
67
67
  const hostRef = docSnapshot.ref.parent.parent;
68
68
  if (!hostRef) continue;
69
- // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-
70
- // row retirement below stamps `notifiedAtMs`, which is a write, and the
71
- // email is a message sent in a suspended merchant's name — both wait.
72
- //
73
- // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the
74
- // next beat, which is exactly the behaviour the `notifiedAtMs == null`
75
- // query already relies on.
76
- if (await gate.isLocked(hostRef.id)) {
77
- skippedLocked += 1;
78
- continue;
79
- }
80
69
  const data = docSnapshot.data();
81
70
  // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated
82
71
  // POST (`notify-restock.ts`), and this line is where it becomes a path
@@ -103,6 +92,30 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
103
92
  productCache.set(cacheKey, productSnapshot.exists ? CommerceModel.liftLegacyProduct(productSnapshot.data()) : null);
104
93
  }
105
94
  const product = productId ? productCache.get(cacheKey) : null;
95
+ // An alert still waiting on its product leaves here, before the gate
96
+ // (AGL-4121): the verdict is three reads, the product is one per run, and
97
+ // this window is mostly shoppers waiting on a shelf that is still empty.
98
+ // Neither filter writes or mails, so neither needs a verdict. They leave
99
+ // the row unstamped, which is what a lock would have done with it.
100
+ if (product) {
101
+ const total = CommerceModel.productInventory(product);
102
+ if (total != null && total <= 0) continue;
103
+ // Sold in person only (AGL-4060): the email's one button is the
104
+ // product's page, which is not served. The alert waits, unstamped, and
105
+ // goes out once the product sells online again.
106
+ if (!CommerceModel.productSellsOnline(product)) continue;
107
+ }
108
+ // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-
109
+ // row retirement below stamps `notifiedAtMs`, which is a write, and the
110
+ // email is a message sent in a suspended merchant's name — both wait.
111
+ //
112
+ // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the
113
+ // next beat, which is exactly the behaviour the `notifiedAtMs == null`
114
+ // query already relies on.
115
+ if (await gate.isLocked(hostRef.id)) {
116
+ skippedLocked += 1;
117
+ continue;
118
+ }
106
119
  if (!product) {
107
120
  await docSnapshot.ref.set({
108
121
  notifiedAtMs: Date.now(),
@@ -112,12 +125,6 @@ import { loadHostEmailWithTokens, renderLoadedHostEmailWithTokens } from "@aglyn
112
125
  }).catch(()=>undefined);
113
126
  continue;
114
127
  }
115
- const total = CommerceModel.productInventory(product);
116
- if (total != null && total <= 0) continue;
117
- // Sold in person only (AGL-4060): the email's one button is the product's
118
- // page, which is not served. The alert waits, unstamped, and goes out once
119
- // the product sells online again.
120
- if (!CommerceModel.productSellsOnline(product)) continue;
121
128
  let loaded = templateCache.get(hostRef.id);
122
129
  if (loaded === undefined) {
123
130
  var _ref1, _Aglyn_hostPublicOrigin;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.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 * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /** Alerts left unstamped because their site is locked (AGL-2495). */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n // Sold in person only (AGL-4060): the email's one button is the product's\n // page, which is not served. The alert waits, unstamped, and goes out once\n // the product sells online again.\n if (!CommerceModel.productSellsOnline(product)) continue\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n // ABSOLUTE, on the site's own origin (AGL-3432): a bare `/products/…`\n // path is a dead link in every inbox, and it is the email's only button.\n const productUrl = `${siteBaseByHost.get(hostRef.id) ?? ''}/products/${product.slug}`\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is back in stock:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: CommerceModel.PRODUCT_UPDATES_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","isLocked","id","data","productId","cacheKey","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","notifiedAtMs","Date","now","skipped","merge","catch","undefined","total","productInventory","productSellsOnline","loaded","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","productUrl","slug","designed","String","name","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","PRODUCT_UPDATES_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/process-restock.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 * as Aglyn from '@aglyn/aglyn/server'\nimport * as CommerceModel from '../model'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport {\n isDeferrableSendResult,\n isEmailConfigured,\n sendEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n} from '@aglyn/aglyn/server'\n// The leaf, not the barrel: the console's `x-cron-secret` door asks the\n// site's lockdown directly (AGL-3356) — core's registry has no resolver in\n// the console process — and a spec that substitutes the barrel must still\n// reach the real verdict.\nimport { siteLockdownJobGate } from '@aglyn/tenant-data-admin/server/tenant-write-lockdown'\nimport {\n loadHostEmailWithTokens,\n renderLoadedHostEmailWithTokens,\n type LoadedHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin/server/host-email-tokens'\n\nexport interface RestockScanResult {\n scanned: number\n sent: number\n /**\n * Alerts owed a stamp or an email and left unstamped because their site is\n * locked (AGL-2495). An alert owed nothing is not asked about (AGL-4121).\n */\n skippedLocked: number\n}\n\n/**\n * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).\n *\n * Same finding as `process-abandoned.ts` beside it: `x-cron-secret`-gated and\n * **never invoked by anything**. `commerce/notify-restock` accepts the\n * shopper's \"tell me when it's back\" from the storefront and writes\n * `hosts/{hostId}/restockAlerts`; this is the only thing that would ever mail\n * them. Shoppers have been entering an address into a queue with no drain.\n *\n * Exported so `server.ts` can put it on the platform job beat.\n */\nexport async function scanRestockAlerts(\n /** The lockdown gate, injected by the caller (AGL-2495). Not optional. */\n gate: PluginJobHostGate,\n): Promise<RestockScanResult> {\n const firestore = firebaseAdmin.app().firestore()\n const alerts = await firestore\n .collectionGroup('restockAlerts')\n .where('notifiedAtMs', '==', null)\n .limit(200)\n .get()\n let sent = 0\n const productCache = new Map<string, CommerceModel.HostProduct | null>()\n // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmailWithTokens | null>()\n // White-label brand per host (White-Label Phase 3): resolved once per host\n // from the owning org doc through the one shared resolver.\n const brandingByHost = new Map<string, Aglyn.ResolvedBrandingProfile>()\n /*\n * One identity resolution per SITE, not per message.\n *\n * The same reason the branding map above exists: this sweep runs across\n * every site, and an uncached resolution here would be two Firestore reads\n * per recipient rather than per host. Scoped to the run, so a domain\n * un-verified by the re-check sweep is picked up on the next one.\n */\n const identityByHost = new Map<\n string,\n Awaited<ReturnType<typeof hostSendingIdentity>>\n >()\n /** Each site's public origin, for the unsubscribe link on the alert. */\n const siteBaseByHost = new Map<string, string>()\n /**\n * Each site's consent group, off the same org read as its branding, so the\n * gate honors an unsubscribe from any site the org declared one sender\n * with this one, and the org's word on whether its sites wait for each\n * other's confirmation click.\n */\n const consentGroupByHost = new Map<string, Aglyn.ConsentGroup>()\n let skippedLocked = 0\n for (const docSnapshot of alerts.docs) {\n const hostRef = docSnapshot.ref.parent.parent\n if (!hostRef) continue\n const data = docSnapshot.data() as any\n // AGL-1774: `productId` is a STORED FIELD written by an unauthenticated\n // POST (`notify-restock.ts`), and this line is where it becomes a path\n // component. `.doc()` appends a slash-separated path and throws\n // SYNCHRONOUSLY on an even component count — a throw that landed here,\n // outside the loop body's own error handling, so it aborted the entire\n // run. The scan is a `collectionGroup`, so that run is platform-wide;\n // and because the offending alert was never stamped `notifiedAtMs` it\n // returned on every subsequent run, taking every alert ordered after it\n // down too. One anonymous request stopped back-in-stock email for every\n // merchant, permanently.\n //\n // `notify-restock.ts` now refuses such a value at the door, but this\n // guard is not redundant with that one: rows written BEFORE the fix are\n // already in the database, and a create-time check does nothing about\n // them. Treated as \"no product\" rather than as an error, which is the\n // correct refusal by AGL-1760's test — there is no product, so there is\n // no email owed and no work to discard — and it stamps the alert below\n // so a poisoned row is retired instead of re-scanned forever.\n const productId = isDocumentId(data.productId) ? data.productId : ''\n const cacheKey = `${hostRef.id}:${productId}`\n if (productId && !productCache.has(cacheKey)) {\n const productSnapshot = await hostRef\n .collection('products')\n .doc(productId)\n .get()\n productCache.set(\n cacheKey,\n productSnapshot.exists\n ? CommerceModel.liftLegacyProduct(productSnapshot.data() as any)\n : null,\n )\n }\n const product = productId ? productCache.get(cacheKey) : null\n // An alert still waiting on its product leaves here, before the gate\n // (AGL-4121): the verdict is three reads, the product is one per run, and\n // this window is mostly shoppers waiting on a shelf that is still empty.\n // Neither filter writes or mails, so neither needs a verdict. They leave\n // the row unstamped, which is what a lock would have done with it.\n if (product) {\n const total = CommerceModel.productInventory(product)\n if (total != null && total <= 0) continue\n // Sold in person only (AGL-4060): the email's one button is the\n // product's page, which is not served. The alert waits, unstamped, and\n // goes out once the product sells online again.\n if (!CommerceModel.productSellsOnline(product)) continue\n }\n // LOCKDOWN (AGL-2495), before anything in this body writes. The poisoned-\n // row retirement below stamps `notifiedAtMs`, which is a write, and the\n // email is a message sent in a suspended merchant's name — both wait.\n //\n // SKIPPED, NOT DROPPED: an alert that is not stamped is re-scanned on the\n // next beat, which is exactly the behaviour the `notifiedAtMs == null`\n // query already relies on.\n if (await gate.isLocked(hostRef.id)) {\n skippedLocked += 1\n continue\n }\n if (!product) {\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now(), skipped: true }, { merge: true })\n .catch(() => undefined)\n continue\n }\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await // The template, or the built-in copy in the site's header and\n // footer, once per site for the whole batch (AGL-3287, AGL-3370).\n loadHostEmailWithTokens(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentGroupByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ),\n )\n // The site's own origin, for the unsubscribe link. Resolved once per\n // host beside the branding, because this sweep is a `collectionGroup`\n // over every site's alerts and a read per alert would be a read per\n // shopper.\n const hostSnapshot = await hostRef.get().catch(() => null)\n siteBaseByHost.set(\n hostRef.id,\n Aglyn.hostPublicOrigin({\n cname: hostSnapshot?.get('cname'),\n subdomain: hostSnapshot?.get('subdomain'),\n }) ?? '',\n )\n }\n // ABSOLUTE, on the site's own origin (AGL-3432): a bare `/products/…`\n // path is a dead link in every inbox, and it is the email's only button.\n const productUrl = `${siteBaseByHost.get(hostRef.id) ?? ''}/products/${product.slug}`\n const designed = loaded\n ? renderLoadedHostEmailWithTokens(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n )\n : null\n /*\n * MARKETING, and `'bulk'` priority, which this sweep is entitled to\n * because it is resumable: an alert left unstamped is re-scanned by the\n * `notifiedAtMs == null` query on the next beat, so a refusal means \"not\n * this hour\" rather than a shopper who asked to be told and never was.\n *\n * The gate adds the unsubscribe header pair and a visible link, checks\n * both suppression lists, and counts this against how much mail the\n * shopper has had from this site today.\n */\n const consentGroup =\n consentGroupByHost.get(hostRef.id) ?? Aglyn.soloConsentGroup(hostRef.id)\n const result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? `Back in stock: ${product.name}`,\n text:\n designed?.text ||\n `${product.name} is back in stock:\\n\\n${productUrl}`,\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: brandingByHost.get(hostRef.id)?.fromName,\n sendingIdentity: await hostSendingIdentity(hostRef.id, identityByHost),\n audience: 'tenant',\n context: 'restock alert',\n priority: 'bulk',\n /*\n * A restock alert IS the \"Product updates\" stream — the catalog entry\n * names restocks in its own description. Declared so a shopper who\n * unticked that stream on the preference page is not mailed it: the\n * gate asks the topic only for callers that name one, so a sender that\n * left this off would keep mailing them.\n */\n marketing: {\n hostId: hostRef.id,\n siteBase: siteBaseByHost.get(hostRef.id) ?? '',\n topicId: CommerceModel.PRODUCT_UPDATES_TOPIC_ID,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n /*\n * SKIPPED, NOT DROPPED — the lockdown rule above, applied to the two\n * refusals a later beat can pass.\n *\n * `notifiedAtMs` is what retires an alert from this sweep. Stamping it\n * after the hourly ceiling or the frequency cap refused would drop a\n * request from a shopper who asked to be told; NOT stamping it after a\n * suppression or a rejection would re-read the same doomed row forever,\n * inside a window of two hundred, starving every alert behind it.\n */\n if (isDeferrableSendResult(result)) continue\n // Cost meter (AGL-1438). One alert per shopper who asked to be told,\n // like the abandoned-checkout reminder beside it, and on the DELIVERED\n // message only — a suppressed recipient produced no message and no cost.\n if (result.sent) await meterHostEmail(hostRef.id)\n await docSnapshot.ref\n .set({ notifiedAtMs: Date.now() }, { merge: true })\n .catch(() => undefined)\n if (result.sent) sent += 1\n }\n return { scanned: alerts.size, sent, skippedLocked }\n}\n\n/**\n * HTTP door for the pass above (AGL-326), kept for manual/ops invocation and\n * for the console's \"send now\" control.\n */\nexport const processRestockHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const cronSecret = process.env.CRON_SECRET\n if (!cronSecret || req.headers['x-cron-secret'] !== cronSecret) {\n return res.status(401).json({ error: 'Unauthorized' })\n }\n if (!isEmailConfigured()) {\n return res.status(501).json({ error: 'Email is not configured.' })\n }\n try {\n // The manual door asks the same question the beat does (AGL-2495).\n return res.status(200).json(await scanRestockAlerts(siteLockdownJobGate()))\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Processing failed' })\n }\n}\n"],"names":["Aglyn","CommerceModel","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","isDocumentId","isDeferrableSendResult","isEmailConfigured","sendEmail","siteLockdownJobGate","loadHostEmailWithTokens","renderLoadedHostEmailWithTokens","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","consentGroupByHost","skippedLocked","docSnapshot","docs","product","hostRef","ref","parent","data","productId","cacheKey","id","has","productSnapshot","collection","doc","set","exists","liftLegacyProduct","total","productInventory","productSellsOnline","isLocked","notifiedAtMs","Date","now","skipped","merge","catch","undefined","loaded","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","productUrl","slug","designed","String","name","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","PRODUCT_UPDATES_TOPIC_ID","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}