@aglyn/plugins-commerce 1.0.0-beta.190 → 1.0.0-beta.191
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 +13 -13
- package/src/lib/server/member-post.js +3 -2
- package/src/lib/server/member-post.js.map +1 -1
- package/src/lib/server/process-abandoned.js +9 -8
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +9 -8
- package/src/lib/server/process-restock.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-commerce",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.191",
|
|
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.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.191",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.191",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.191",
|
|
31
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.191",
|
|
32
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.191",
|
|
33
|
+
"@aglyn/shared-util-email": "1.0.0-beta.191",
|
|
34
|
+
"@aglyn/shared-util-http": "1.0.0-beta.191",
|
|
35
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.191",
|
|
36
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.191",
|
|
37
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.191",
|
|
38
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.191",
|
|
39
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.191",
|
|
40
40
|
"@stripe/react-stripe-js": "^3.10.0",
|
|
41
41
|
"@stripe/stripe-js": "^5.10.0",
|
|
42
42
|
"@swc/helpers": "0.5.23",
|
|
@@ -97,7 +97,7 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
|
|
|
97
97
|
const branding = resolveBrandingProfile(owner == null ? void 0 : owner.org);
|
|
98
98
|
// The site's consent group, off the same org read: a member who left
|
|
99
99
|
// any site the org declared one sender with this one is not mailed.
|
|
100
|
-
const
|
|
100
|
+
const consentGroup = consentGroupForHost((_ref = owner == null ? void 0 : owner.org) != null ? _ref : null, hostId);
|
|
101
101
|
// The site's own origin, for the unsubscribe link the gate mints. Read
|
|
102
102
|
// from the host document already in hand rather than assembled from an
|
|
103
103
|
// apex: a post is mailed and read later, and a wrong origin sends a
|
|
@@ -141,7 +141,8 @@ import { consentGroupForHost, EMAIL_TOPIC_NEWSLETTER, hostPublicOrigin, resolveB
|
|
|
141
141
|
hostId,
|
|
142
142
|
siteBase,
|
|
143
143
|
topicId: EMAIL_TOPIC_NEWSLETTER,
|
|
144
|
-
consentHostIds
|
|
144
|
+
consentHostIds: consentGroup.hostIds,
|
|
145
|
+
consentAwaitsConfirmation: consentGroup.awaitsConfirmation
|
|
145
146
|
}
|
|
146
147
|
});
|
|
147
148
|
// The cost meter counts messages that LEFT. A suppressed or capped
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentHostIds = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ).hostIds\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: title,\n text: postBody || title,\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentHostIds","hostIds","siteBase","cname","subdomain","identity","to","result","subject","text","fromName","sendingIdentity","audience","context","marketing","topicId","sent","postId","id","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/member-post.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 {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n} from '@aglyn/tenant-data-admin'\nimport * as CommerceModel from '../model'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport {\n consentGroupForHost,\n EMAIL_TOPIC_NEWSLETTER,\n hostPublicOrigin,\n type PluginApiHandler,\n resolveBrandingProfile,\n} from '@aglyn/aglyn/server'\n\n// Which member subscribers are live enough to email (AGL-316). The list itself\n// now lives in the model as `isTenantSubscriptionLive` (AGL-1849) — it was a\n// private copy here and a second private copy in `gate.ts`, which is the drift\n// AGL-1715 guards against on the org side.\n\n/**\n * Member post publish (AGL-316): manager-gated; writes the post and\n * optionally emails the entitled subscribers (product-scoped or all\n * live subscribers) through the env-gated Resend pipeline.\n */\nexport const memberPostHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return res.status(401).json({ error: 'Unauthenticated' })\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const title = String(body.title ?? '').trim().slice(0, 160)\n const postBody = String(body.body ?? '').trim().slice(0, 5000)\n const productId = String(body.productId ?? '').trim()\n const emailSubscribers = Boolean(body.emailSubscribers)\n if (!hostId || !title) {\n return res.status(400).json({ error: 'Missing hostId or title' })\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n // AN ALLOWLIST (AGL-2372), matching `gift-cards.ts` and the AGL-2262\n // correction to the register. The old `!role || role === 'viewer'`\n // denylist admitted every string that was not literally `viewer` — and\n // since AGL-2334 that includes `author`, a real grantable role, on a route\n // that publishes to paying subscribers and sends them mail on the\n // merchant's brand. A denylist that widens whenever the role union grows\n // is the defect; the set is the fix. Absent is refused, not permitted.\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return res.status(403).json({ error: 'Not permitted' })\n }\n const postRef = await hostRef.collection('memberPosts').add({\n title,\n body: postBody,\n ...(productId ? { productId } : {}),\n createdAtMs: Date.now(),\n createdBy: decoded.uid,\n })\n\n let emailed = 0\n if (emailSubscribers && isEmailConfigured()) {\n /*\n * ORDERED (D1). This was `limit(500)` with no `orderBy`, and Firestore\n * answers an unordered limit in DOCUMENT-ID order — so a site past the\n * ceiling mailed whichever five hundred subscriptions happened to hash\n * low, and the same five hundred every time. `__name__` is the order it\n * was already getting; stating it makes the window a decision rather\n * than an accident, and makes it the same window on every publish.\n */\n const subscriptions = await hostRef\n .collection('subscriptions')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(500)\n .get()\n const recipients = [\n ...new Set(\n subscriptions.docs\n .filter(\n (docSnapshot) =>\n CommerceModel.isTenantSubscriptionLive(\n docSnapshot.get('status'),\n ) &&\n (!productId ||\n docSnapshot.get('productId') === productId) &&\n docSnapshot.get('customerEmail'),\n )\n .map((docSnapshot) => String(docSnapshot.get('customerEmail'))),\n ),\n ].slice(0, 200)\n // White-label sender identity (White-Label Phase 3): the store's brand\n // via the one shared resolver, from the owning org doc.\n const owner = await getOrgForHost(hostId).catch(() => null)\n const branding = resolveBrandingProfile(owner?.org as never)\n // The site's consent group, off the same org read: a member who left\n // any site the org declared one sender with this one is not mailed.\n const consentGroup = consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n )\n // The site's own origin, for the unsubscribe link the gate mints. Read\n // from the host document already in hand rather than assembled from an\n // apex: a post is mailed and read later, and a wrong origin sends a\n // subscriber's opt-out to a domain the merchant does not control.\n const siteBase =\n hostPublicOrigin({\n cname: hostSnapshot.get('cname'),\n subdomain: hostSnapshot.get('subdomain'),\n }) ?? ''\n /*\n * ONE site, up to two hundred recipients — so the identity is resolved\n * once and reused, not read per message. Hoisted rather than cached\n * inside the helper for the reason its docblock gives: verification can\n * change under a long-lived process, so how long a resolution may be\n * trusted is the caller's declaration.\n */\n const identity = await hostSendingIdentity(hostId)\n for (const to of recipients) {\n /*\n * MARKETING. A member post is a merchant mailing their audience, so\n * it carries the unsubscribe header pair and a visible link, it is\n * checked against both suppression lists, and it counts against how\n * much mail one person receives from this site.\n *\n * Priority stays transactional — this handler answers a click and has\n * nowhere to put a message the hourly governor deferred, and the rule\n * on `'bulk'` is that only a resumable sweep may use it.\n */\n const result = await sendEmail({\n to,\n subject: title,\n text: postBody || title,\n fromName: branding.fromName,\n sendingIdentity: identity,\n audience: 'tenant',\n context: 'member post',\n /*\n * A post to a site's members is the \"Newsletter\" stream — regular\n * news and stories from the site. Declared so a member who left\n * that stream, while staying subscribed to everything else, is not\n * mailed it; the gate asks the topic only for callers that name\n * one.\n */\n marketing: {\n hostId,\n siteBase,\n topicId: EMAIL_TOPIC_NEWSLETTER,\n consentHostIds: consentGroup.hostIds,\n consentAwaitsConfirmation: consentGroup.awaitsConfirmation,\n },\n })\n // The cost meter counts messages that LEFT. A suppressed or capped\n // recipient produced no message and therefore no cost, and counting\n // one would bill a merchant for the control working.\n if (result.sent) emailed += 1\n }\n // Cost meter (AGL-1438), once for the batch rather than per recipient —\n // same number, one write. Transactional: a member post is content the\n // subscriber is paying for, not a discretionary campaign, so it counts\n // toward cost and is never refused by the campaign cap.\n await meterHostEmail(hostId, emailed)\n }\n return res.status(200).json({ postId: postRef.id, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Publish failed' })\n }\n}\n"],"names":["firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","CommerceModel","isEmailConfigured","sendEmail","consentGroupForHost","EMAIL_TOPIC_NEWSLETTER","hostPublicOrigin","resolveBrandingProfile","memberPostHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","title","trim","postBody","productId","emailSubscribers","Boolean","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","memberRole","uid","postRef","add","createdAtMs","Date","now","createdBy","emailed","subscriptions","orderBy","FieldPath","documentId","limit","recipients","Set","docs","filter","docSnapshot","isTenantSubscriptionLive","map","owner","catch","branding","org","consentGroup","siteBase","cname","subdomain","identity","to","result","subject","text","fromName","sendingIdentity","audience","context","marketing","topicId","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","sent","postId","id","console"],"mappings":"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"}
|
|
@@ -64,13 +64,14 @@ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
64
64
|
/**
|
|
65
65
|
* Each site's consent group, off the same org read as its entitlement, so
|
|
66
66
|
* the gate honors an unsubscribe from any site the org declared one sender
|
|
67
|
-
* with this one
|
|
68
|
-
|
|
67
|
+
* with this one, and the org's word on whether its sites wait for each
|
|
68
|
+
* other's confirmation click.
|
|
69
|
+
*/ const consentGroupByHost = new Map();
|
|
69
70
|
// Resolve each host's designed template once per run (AGL-770).
|
|
70
71
|
const templateCache = new Map();
|
|
71
72
|
let skippedLocked = 0;
|
|
72
73
|
for (const docSnapshot of openCheckouts.docs){
|
|
73
|
-
var _data_createdAtMs, _data_resumeUrl, _ref, _data_resumeUrl1, _siteBaseByHost_get
|
|
74
|
+
var _data_createdAtMs, _data_resumeUrl, _consentGroupByHost_get, _ref, _data_resumeUrl1, _siteBaseByHost_get;
|
|
74
75
|
var _docSnapshot_ref_parent_parent, _brandingByHost_get;
|
|
75
76
|
const data = docSnapshot.data();
|
|
76
77
|
// `hosts/{hostId}/checkouts/{id}` — the grandparent is the host.
|
|
@@ -105,7 +106,7 @@ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
105
106
|
const org = await getOrgForHost(hostId).catch(()=>null);
|
|
106
107
|
entitledHosts.set(hostId, Aglyn.checkEntitlement(org == null ? void 0 : org.org, 'abandonedCart'));
|
|
107
108
|
brandingByHost.set(hostId, Aglyn.resolveBrandingProfile(org == null ? void 0 : org.org));
|
|
108
|
-
|
|
109
|
+
consentGroupByHost.set(hostId, Aglyn.consentGroupForHost((_ref1 = org == null ? void 0 : org.org) != null ? _ref1 : null, hostId));
|
|
109
110
|
// The site's own origin, for the unsubscribe link. Resolved once per
|
|
110
111
|
// host for the same reason the branding beside it is: this sweep is a
|
|
111
112
|
// `collectionGroup` over every site's checkouts, and a read per
|
|
@@ -138,7 +139,8 @@ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
138
139
|
* The gate adds the unsubscribe header pair and a visible link, checks
|
|
139
140
|
* both suppression lists, and counts this against how much mail the
|
|
140
141
|
* shopper has had from this site today.
|
|
141
|
-
*/ const
|
|
142
|
+
*/ const consentGroup = (_consentGroupByHost_get = consentGroupByHost.get(hostId)) != null ? _consentGroupByHost_get : Aglyn.soloConsentGroup(hostId);
|
|
143
|
+
const result = await sendEmail(_extends({
|
|
142
144
|
to: String(data.email),
|
|
143
145
|
subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : 'You left something in your cart',
|
|
144
146
|
text: (designed == null ? void 0 : designed.text) || 'Your cart is still waiting — pick up where you left off:\n\n' + `${(_data_resumeUrl1 = data.resumeUrl) != null ? _data_resumeUrl1 : ''}\n\n` + 'Your items are held but not reserved, so they may sell out.'
|
|
@@ -159,9 +161,8 @@ const GIVE_UP_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
159
161
|
hostId,
|
|
160
162
|
siteBase: (_siteBaseByHost_get = siteBaseByHost.get(hostId)) != null ? _siteBaseByHost_get : '',
|
|
161
163
|
topicId: Aglyn.EMAIL_TOPIC_MARKETING,
|
|
162
|
-
consentHostIds:
|
|
163
|
-
|
|
164
|
-
]
|
|
164
|
+
consentHostIds: consentGroup.hostIds,
|
|
165
|
+
consentAwaitsConfirmation: consentGroup.awaitsConfirmation
|
|
165
166
|
}
|
|
166
167
|
}));
|
|
167
168
|
/*
|
|
@@ -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'\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 /**\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.\n */\n const consentHostIdsByHost = new Map<string, readonly 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 consentHostIdsByHost.set(\n hostId,\n Aglyn.consentGroupForHost(\n (org?.org as Record<string, unknown> | undefined) ?? null,\n hostId,\n ).hostIds,\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 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 consentHostIds: consentHostIdsByHost.get(hostId) ?? [hostId],\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","consentHostIdsByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","email","remindedAtMs","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostIds","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","consentHostIds","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 /**\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, 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 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 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 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 '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 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 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","consentGroupByHost","templateCache","skippedLocked","docSnapshot","docs","data","hostId","ref","parent","id","isLocked","createdAtMs","Number","email","remindedAtMs","set","status","merge","catch","undefined","has","org","checkEntitlement","resolveBrandingProfile","consentGroupForHost","hostSnapshot","collection","doc","hostPublicOrigin","cname","subdomain","loaded","compose","designed","String","resumeUrl","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","siteBase","topicId","EMAIL_TOPIC_MARKETING","consentHostIds","hostIds","consentAwaitsConfirmation","awaitsConfirmation","scanned","size","processAbandonedHandler","req","res","method","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":"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"}
|
|
@@ -53,11 +53,12 @@ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-reference
|
|
|
53
53
|
/**
|
|
54
54
|
* Each site's consent group, off the same org read as its branding, so the
|
|
55
55
|
* gate honors an unsubscribe from any site the org declared one sender
|
|
56
|
-
* with this one
|
|
57
|
-
|
|
56
|
+
* with this one, and the org's word on whether its sites wait for each
|
|
57
|
+
* other's confirmation click.
|
|
58
|
+
*/ const consentGroupByHost = new Map();
|
|
58
59
|
let skippedLocked = 0;
|
|
59
60
|
for (const docSnapshot of alerts.docs){
|
|
60
|
-
var _product_name, _ref, _siteBaseByHost_get
|
|
61
|
+
var _product_name, _consentGroupByHost_get, _ref, _siteBaseByHost_get;
|
|
61
62
|
var _brandingByHost_get;
|
|
62
63
|
const hostRef = docSnapshot.ref.parent.parent;
|
|
63
64
|
if (!hostRef) continue;
|
|
@@ -121,7 +122,7 @@ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-reference
|
|
|
121
122
|
templateCache.set(hostRef.id, loaded);
|
|
122
123
|
const owner = await getOrgForHost(hostRef.id).catch(()=>null);
|
|
123
124
|
brandingByHost.set(hostRef.id, Aglyn.resolveBrandingProfile(owner == null ? void 0 : owner.org));
|
|
124
|
-
|
|
125
|
+
consentGroupByHost.set(hostRef.id, Aglyn.consentGroupForHost((_ref1 = owner == null ? void 0 : owner.org) != null ? _ref1 : null, hostRef.id));
|
|
125
126
|
// The site's own origin, for the unsubscribe link. Resolved once per
|
|
126
127
|
// host beside the branding, because this sweep is a `collectionGroup`
|
|
127
128
|
// over every site's alerts and a read per alert would be a read per
|
|
@@ -145,7 +146,8 @@ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-reference
|
|
|
145
146
|
* The gate adds the unsubscribe header pair and a visible link, checks
|
|
146
147
|
* both suppression lists, and counts this against how much mail the
|
|
147
148
|
* shopper has had from this site today.
|
|
148
|
-
*/ const
|
|
149
|
+
*/ const consentGroup = (_consentGroupByHost_get = consentGroupByHost.get(hostRef.id)) != null ? _consentGroupByHost_get : Aglyn.soloConsentGroup(hostRef.id);
|
|
150
|
+
const result = await sendEmail(_extends({
|
|
149
151
|
to: String(data.email),
|
|
150
152
|
subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : `Back in stock: ${product.name}`,
|
|
151
153
|
text: (designed == null ? void 0 : designed.text) || `${product.name} is available again — grab it before it sells ` + `out:\n\n${productUrl}`
|
|
@@ -167,9 +169,8 @@ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-reference
|
|
|
167
169
|
hostId: hostRef.id,
|
|
168
170
|
siteBase: (_siteBaseByHost_get = siteBaseByHost.get(hostRef.id)) != null ? _siteBaseByHost_get : '',
|
|
169
171
|
topicId: Aglyn.EMAIL_TOPIC_PRODUCT_UPDATES,
|
|
170
|
-
consentHostIds:
|
|
171
|
-
|
|
172
|
-
]
|
|
172
|
+
consentHostIds: consentGroup.hostIds,
|
|
173
|
+
consentAwaitsConfirmation: consentGroup.awaitsConfirmation
|
|
173
174
|
}
|
|
174
175
|
}));
|
|
175
176
|
/*
|
|
@@ -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'\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 /**\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.\n */\n const consentHostIdsByHost = new Map<string, readonly 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 const owner = await getOrgForHost(hostRef.id).catch(() => null)\n brandingByHost.set(\n hostRef.id,\n Aglyn.resolveBrandingProfile(owner?.org as never),\n )\n consentHostIdsByHost.set(\n hostRef.id,\n Aglyn.consentGroupForHost(\n (owner?.org as Record<string, unknown> | undefined) ?? null,\n hostRef.id,\n ).hostIds,\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 consentHostIds: consentHostIdsByHost.get(hostRef.id) ?? [hostRef.id],\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","consentHostIdsByHost","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","owner","resolveBrandingProfile","org","consentGroupForHost","hostIds","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","consentHostIds","scanned","size","processRestockHandler","req","res","method","status","json","error","cronSecret","process","env","CRON_SECRET","headers","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,YAAYC,mBAAmB,oBAAU;AACzC,SACEC,aAAa,EACbC,aAAa,EACbC,mBAAmB,EACnBC,cAAc,QACT,2BAA0B;AACjC,SAASC,YAAY,QAAQ,8CAA6C;AAC1E,SACEC,sBAAsB,EACtBC,iBAAiB,EACjBC,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QAEJ,2BAA0B;AACjC,SAGEC,iBAAiB,QACZ,sBAAqB;AAC5B,SAASC,yBAAyB,QAAQ,oDAAmD;AAS7F;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,kBACpB,wEAAwE,GACxEC,IAAuB;IAEvB,MAAMC,YAAYd,cAAce,GAAG,GAAGD,SAAS;IAC/C,MAAME,SAAS,MAAMF,UAClBG,eAAe,CAAC,iBAChBC,KAAK,CAAC,gBAAgB,MAAM,MAC5BC,KAAK,CAAC,KACNC,GAAG;IACN,IAAIC,OAAO;IACX,MAAMC,eAAe,IAAIC;IACzB,gEAAgE;IAChE,MAAMC,gBAAgB,IAAID;IAC1B,2EAA2E;IAC3E,2DAA2D;IAC3D,MAAME,iBAAiB,IAAIF;IAC3B;;;;;;;GAOC,GACD,MAAMG,iBAAiB,IAAIH;IAI3B,sEAAsE,GACtE,MAAMI,iBAAiB,IAAIJ;IAC3B;;;;GAIC,GACD,MAAMK,uBAAuB,IAAIL;IACjC,IAAIM,gBAAgB;IACpB,KAAK,MAAMC,eAAed,OAAOe,IAAI,CAAE;YA8FNC,qBAsCjBL,qBAEMC;YAhBRH;QArHZ,MAAMQ,UAAUH,YAAYI,GAAG,CAACC,MAAM,CAACA,MAAM;QAC7C,IAAI,CAACF,SAAS;QACd,0EAA0E;QAC1E,wEAAwE;QACxE,sEAAsE;QACtE,EAAE;QACF,0EAA0E;QAC1E,uEAAuE;QACvE,2BAA2B;QAC3B,IAAI,MAAMpB,KAAKuB,QAAQ,CAACH,QAAQI,EAAE,GAAG;YACnCR,iBAAiB;YACjB;QACF;QACA,MAAMS,OAAOR,YAAYQ,IAAI;QAC7B,wEAAwE;QACxE,uEAAuE;QACvE,gEAAgE;QAChE,uEAAuE;QACvE,uEAAuE;QACvE,sEAAsE;QACtE,sEAAsE;QACtE,wEAAwE;QACxE,wEAAwE;QACxE,yBAAyB;QACzB,EAAE;QACF,qEAAqE;QACrE,wEAAwE;QACxE,sEAAsE;QACtE,sEAAsE;QACtE,wEAAwE;QACxE,uEAAuE;QACvE,8DAA8D;QAC9D,MAAMC,YAAYnC,aAAakC,KAAKC,SAAS,IAAID,KAAKC,SAAS,GAAG;QAClE,MAAMC,WAAW,GAAGP,QAAQI,EAAE,CAAC,CAAC,EAAEE,WAAW;QAC7C,IAAIA,aAAa,CAACjB,aAAamB,GAAG,CAACD,WAAW;YAC5C,MAAME,kBAAkB,MAAMT,QAC3BU,UAAU,CAAC,YACXC,GAAG,CAACL,WACJnB,GAAG;YACNE,aAAauB,GAAG,CACdL,UACAE,gBAAgBI,MAAM,GAClB/C,cAAcgD,iBAAiB,CAACL,gBAAgBJ,IAAI,MACpD;QAER;QACA,MAAMN,UAAUO,YAAYjB,aAAaF,GAAG,CAACoB,YAAY;QACzD,IAAI,CAACR,SAAS;YACZ,MAAMF,YAAYI,GAAG,CAClBW,GAAG,CAAC;gBAAEG,cAAcC,KAAKC,GAAG;gBAAIC,SAAS;YAAK,GAAG;gBAAEC,OAAO;YAAK,GAC/DC,KAAK,CAAC,IAAMC;YACf;QACF;QACA,MAAMC,QAAQxD,cAAcyD,gBAAgB,CAACxB;QAC7C,IAAIuB,SAAS,QAAQA,SAAS,GAAG;QACjC,MAAME,aAAa,CAAC,UAAU,EAAEzB,QAAQ0B,IAAI,EAAE;QAC9C,IAAIC,SAASnC,cAAcJ,GAAG,CAACa,QAAQI,EAAE;QACzC,IAAIsB,WAAWL,WAAW;uBA0BtBxD;YAzBF6D,SAAS,MAAMpD,cAAcO,WAAWmB,QAAQI,EAAE,EAAE,iBAAiB;gBACnE,qEAAqE;gBACrE,0BAA0B;gBAC1BuB,SAASjD;YACX;YACAa,cAAcqB,GAAG,CAACZ,QAAQI,EAAE,EAAEsB;YAC9B,MAAME,QAAQ,MAAM5D,cAAcgC,QAAQI,EAAE,EAAEgB,KAAK,CAAC,IAAM;YAC1D5B,eAAeoB,GAAG,CAChBZ,QAAQI,EAAE,EACVvC,MAAMgE,sBAAsB,CAACD,yBAAAA,MAAOE,GAAG;YAEzCnC,qBAAqBiB,GAAG,CACtBZ,QAAQI,EAAE,EACVvC,MAAMkE,mBAAmB,UACtBH,yBAAAA,MAAOE,GAAG,oBAA4C,MACvD9B,QAAQI,EAAE,EACV4B,OAAO;YAEX,qEAAqE;YACrE,sEAAsE;YACtE,oEAAoE;YACpE,WAAW;YACX,MAAMC,eAAe,MAAMjC,QAAQb,GAAG,GAAGiC,KAAK,CAAC,IAAM;YACrD1B,eAAekB,GAAG,CAChBZ,QAAQI,EAAE,GACVvC,0BAAAA,MAAMqE,gBAAgB,CAAC;gBACrBC,KAAK,EAAEF,gCAAAA,aAAc9C,GAAG,CAAC;gBACzBiD,SAAS,EAAEH,gCAAAA,aAAc9C,GAAG,CAAC;YAC/B,cAHAtB,0BAGM;QAEV;QACA,MAAMwE,WAAWX,SACbnD,sBACEmD,QACA;YACE,gBAAgBY,QAAOvC,gBAAAA,QAAQwC,IAAI,YAAZxC,gBAAgB;YACvC,eAAeyB;QACjB,GACA3D,MAAM2E,kBAAkB,IAE1B;QACJ;;;;;;;;;KASC,GACD,MAAMC,SAAS,MAAMjE,UAAU;YAC7BkE,IAAIJ,OAAOjC,KAAKsC,KAAK;YACrBC,OAAO,UAAEP,4BAAAA,SAAUO,OAAO,mBAAI,CAAC,eAAe,EAAE7C,QAAQwC,IAAI,EAAE;YAC9DM,MACER,CAAAA,4BAAAA,SAAUQ,IAAI,KACd,GAAG9C,QAAQwC,IAAI,CAAC,8CAA8C,CAAC,GAC7D,CAAC,QAAQ,EAAEf,YAAY;WACvBa,CAAAA,4BAAAA,SAAUS,IAAI,IAAG;YAAEA,MAAMT,SAASS,IAAI;QAAC,IAAI,CAAC;YAChDC,QAAQ,GAAEvD,sBAAAA,eAAeL,GAAG,CAACa,QAAQI,EAAE,sBAA7BZ,oBAAgCuD,QAAQ;YAClDC,iBAAiB,MAAM/E,oBAAoB+B,QAAQI,EAAE,EAAEX;YACvDwD,UAAU;YACVC,SAAS;YACTC,UAAU;YACV;;;;;;OAMC,GACDC,WAAW;gBACTC,QAAQrD,QAAQI,EAAE;gBAClBkD,QAAQ,GAAE5D,sBAAAA,eAAeP,GAAG,CAACa,QAAQI,EAAE,aAA7BV,sBAAkC;gBAC5C6D,SAAS1F,MAAM2F,2BAA2B;gBAC1CC,cAAc,GAAE9D,4BAAAA,qBAAqBR,GAAG,CAACa,QAAQI,EAAE,aAAnCT,4BAAwC;oBAACK,QAAQI,EAAE;iBAAC;YACtE;;QAEF;;;;;;;;;KASC,GACD,IAAIhC,uBAAuBqE,SAAS;QACpC,qEAAqE;QACrE,uEAAuE;QACvE,yEAAyE;QACzE,IAAIA,OAAOrD,IAAI,EAAE,MAAMlB,eAAe8B,QAAQI,EAAE;QAChD,MAAMP,YAAYI,GAAG,CAClBW,GAAG,CAAC;YAAEG,cAAcC,KAAKC,GAAG;QAAG,GAAG;YAAEE,OAAO;QAAK,GAChDC,KAAK,CAAC,IAAMC;QACf,IAAIoB,OAAOrD,IAAI,EAAEA,QAAQ;IAC3B;IACA,OAAO;QAAEsE,SAAS3E,OAAO4E,IAAI;QAAEvE;QAAMQ;IAAc;AACrD;AAEA;;;CAGC,GACD,OAAO,MAAMgE,wBAA0C,OAAOC,KAAKC;IACjE,IAAID,IAAIE,MAAM,KAAK,QAAQ;QACzB,OAAOD,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,aAAaC,QAAQC,GAAG,CAACC,WAAW;IAC1C,IAAI,CAACH,cAAcN,IAAIU,OAAO,CAAC,gBAAgB,KAAKJ,YAAY;QAC9D,OAAOL,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAe;IACtD;IACA,IAAI,CAAC7F,qBAAqB;QACxB,OAAOyF,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA2B;IAClE;IACA,IAAI;QACF,mEAAmE;QACnE,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC,MAAMtF,kBAAkBF;IACtD,EAAE,OAAOyF,OAAO;QACdM,QAAQN,KAAK,CAACA;QACd,OAAOJ,IAAIE,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAoB;IAC3D;AACF,EAAC"}
|
|
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 /**\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 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 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 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 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 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 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(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","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","productUrl","slug","loaded","compose","owner","resolveBrandingProfile","org","consentGroupForHost","hostSnapshot","hostPublicOrigin","cname","subdomain","designed","String","name","sanitizeAuthorHtml","consentGroup","soloConsentGroup","result","to","email","subject","text","html","fromName","sendingIdentity","audience","context","priority","marketing","hostId","siteBase","topicId","EMAIL_TOPIC_PRODUCT_UPDATES","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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"}
|