@aglyn/plugins-commerce 1.0.0-beta.182 → 1.0.0-beta.183

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.182",
3
+ "version": "1.0.0-beta.183",
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.182",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.182",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.182",
31
- "@aglyn/shared-ui-next": "1.0.0-beta.182",
32
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.182",
33
- "@aglyn/shared-util-email": "1.0.0-beta.182",
34
- "@aglyn/shared-util-http": "1.0.0-beta.182",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.182",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.182",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.182",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.182",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.182",
28
+ "@aglyn/aglyn": "1.0.0-beta.183",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.183",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.183",
31
+ "@aglyn/shared-ui-next": "1.0.0-beta.183",
32
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.183",
33
+ "@aglyn/shared-util-email": "1.0.0-beta.183",
34
+ "@aglyn/shared-util-http": "1.0.0-beta.183",
35
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.183",
36
+ "@aglyn/shared-util-tools": "1.0.0-beta.183",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.183",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.183",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.183",
40
40
  "@stripe/react-stripe-js": "^3.10.0",
41
41
  "@stripe/stripe-js": "^5.10.0",
42
42
  "@swc/helpers": "0.5.23",
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } from "@aglyn/tenant-data-admin";
19
19
  import { isDeferrableSendResult, isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
20
20
  import { pluginJobHostGate } from "@aglyn/aglyn/server";
21
+ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
21
22
  const REMIND_AFTER_MS = 60 * 60 * 1000;
22
23
  const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
23
24
  /**
@@ -112,7 +113,11 @@ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
112
113
  if (!entitledHosts.get(hostId)) continue;
113
114
  let loaded = templateCache.get(hostId);
114
115
  if (loaded === undefined) {
115
- loaded = await loadHostEmail(firestore, hostId, 'abandoned-cart');
116
+ loaded = await loadHostEmail(firestore, hostId, 'abandoned-cart', {
117
+ // The site's header and footer blocks, grafted once per site for the
118
+ // whole sweep (AGL-3287).
119
+ compose: composeHostComponentNodes
120
+ });
116
121
  templateCache.set(hostId, loaded);
117
122
  }
118
123
  const designed = loaded ? renderLoadedHostEmail(loaded, {
@@ -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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\n\nconst REMIND_AFTER_MS = 60 * 60 * 1000\nconst GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\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 // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | 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 if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\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 (!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 // 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 loadHostEmail(firestore, hostId, 'abandoned-cart')\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n Aglyn.sanitizeAuthorHtml,\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 result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'Your cart is still waiting — pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n 'Your items are held but not reserved, so they may sell out.',\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.EMAIL_TOPIC_MARKETING,\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 await docSnapshot.ref\n .set({ remindedAtMs: now }, { 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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","REMIND_AFTER_MS","GIVE_UP_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","email","remindedAtMs","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","designed","String","resumeUrl","sanitizeAuthorHtml","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\n\nconst REMIND_AFTER_MS = 60 * 60 * 1000\nconst GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000\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 // Resolve each host's designed template once per run (AGL-770).\n const templateCache = new Map<string, LoadedHostEmail | 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 if (!data.email || data.remindedAtMs) continue\n if (now - createdAtMs < REMIND_AFTER_MS) continue\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 (!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 // 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 loadHostEmail(firestore, hostId, 'abandoned-cart', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostId, loaded)\n }\n const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n { 'cart.url': String(data.resumeUrl ?? '') },\n Aglyn.sanitizeAuthorHtml,\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 result = await sendEmail({\n to: String(data.email),\n subject: designed?.subject ?? 'You left something in your cart',\n text:\n designed?.text ||\n 'Your cart is still waiting — pick up where you left off:\\n\\n' +\n `${data.resumeUrl ?? ''}\\n\\n` +\n 'Your items are held but not reserved, so they may sell out.',\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.EMAIL_TOPIC_MARKETING,\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 await docSnapshot.ref\n .set({ remindedAtMs: now }, { 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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","composeHostComponentNodes","REMIND_AFTER_MS","GIVE_UP_AFTER_MS","scanAbandonedCheckouts","gate","firestore","app","now","Date","openCheckouts","collectionGroup","where","limit","get","sent","entitledHosts","Map","brandingByHost","identityByHost","siteBaseByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","email","remindedAtMs","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","compose","designed","String","resumeUrl","sanitizeAuthorHtml","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
@@ -20,6 +20,7 @@ import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail } fro
20
20
  import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
21
21
  import { isDeferrableSendResult, isEmailConfigured, loadHostEmail, renderLoadedHostEmail, sendEmail } from "@aglyn/shared-util-email";
22
22
  import { pluginJobHostGate } from "@aglyn/aglyn/server";
23
+ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
23
24
  /**
24
25
  * The back-in-stock pass itself, separated from its HTTP door (AGL-2227).
25
26
  *
@@ -108,7 +109,11 @@ import { pluginJobHostGate } from "@aglyn/aglyn/server";
108
109
  if (loaded === undefined) {
109
110
  var _Aglyn_hostPublicOrigin;
110
111
  var _this;
111
- loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock');
112
+ loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {
113
+ // The site's header and footer blocks, grafted once per site for the
114
+ // whole sweep (AGL-3287).
115
+ compose: composeHostComponentNodes
116
+ });
112
117
  templateCache.set(hostRef.id, loaded);
113
118
  brandingByHost.set(hostRef.id, Aglyn.resolveBrandingProfile((_this = await getOrgForHost(hostRef.id).catch(()=>null)) == null ? void 0 : _this.org));
114
119
  // The site's own origin, for the unsubscribe link. Resolved once per
@@ -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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\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, LoadedHostEmail | 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 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 const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock')\n templateCache.set(hostRef.id, loaded)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(\n (await getOrgForHost(hostRef.id).catch(() => null))?.org as never,\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 const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n Aglyn.sanitizeAuthorHtml,\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 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 available again — grab it before it sells ` +\n `out:\\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: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","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","productUrl","slug","loaded","resolveBrandingProfile","org","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","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 loadHostEmail,\n renderLoadedHostEmail,\n sendEmail,\n type LoadedHostEmail,\n} from '@aglyn/shared-util-email'\nimport {\n type PluginApiHandler,\n type PluginJobHostGate,\n pluginJobHostGate,\n} from '@aglyn/aglyn/server'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\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, LoadedHostEmail | 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 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 const productUrl = `/products/${product.slug}`\n let loaded = templateCache.get(hostRef.id)\n if (loaded === undefined) {\n loaded = await loadHostEmail(firestore, hostRef.id, 'back-in-stock', {\n // The site's header and footer blocks, grafted once per site for the\n // whole sweep (AGL-3287).\n compose: composeHostComponentNodes,\n })\n templateCache.set(hostRef.id, loaded)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(\n (await getOrgForHost(hostRef.id).catch(() => null))?.org as never,\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 const designed = loaded\n ? renderLoadedHostEmail(\n loaded,\n {\n 'product.name': String(product.name ?? ''),\n 'product.url': productUrl,\n },\n Aglyn.sanitizeAuthorHtml,\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 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 available again — grab it before it sells ` +\n `out:\\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: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,\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(pluginJobHostGate()))\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","loadHostEmail","renderLoadedHostEmail","sendEmail","pluginJobHostGate","composeHostComponentNodes","scanRestockAlerts","gate","firestore","app","alerts","collectionGroup","where","limit","get","sent","productCache","Map","templateCache","brandingByHost","identityByHost","siteBaseByHost","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","productUrl","slug","loaded","compose","resolveBrandingProfile","org","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}