@aglyn/plugins-commerce 1.0.0-beta.221 → 1.0.0-beta.223
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/README.md +4 -2
- package/package.json +14 -13
- package/src/lib/components/console/catalog-organization-card.component.js +9 -0
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/discounts-card.component.js +8 -0
- package/src/lib/components/console/discounts-card.component.js.map +1 -1
- package/src/lib/components/console/gift-cards-card.component.js +8 -0
- package/src/lib/components/console/gift-cards-card.component.js.map +1 -1
- package/src/lib/components/console/host-coupons-card.component.js +8 -0
- package/src/lib/components/console/host-coupons-card.component.js.map +1 -1
- package/src/lib/components/console/host-orders-card.component.js +34 -70
- package/src/lib/components/console/host-orders-card.component.js.map +1 -1
- package/src/lib/components/console/product-zones.d.ts +7 -6
- package/src/lib/components/console/product-zones.js +5 -4
- package/src/lib/components/console/product-zones.js.map +1 -1
- package/src/lib/components/console/products-hub-card.component.js +86 -284
- package/src/lib/components/console/products-hub-card.component.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +67 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/model/commerce-gift-cards.d.ts +26 -0
- package/src/lib/model/commerce-gift-cards.js +42 -0
- package/src/lib/model/commerce-gift-cards.js.map +1 -1
- package/src/lib/model/commerce-orders.d.ts +0 -57
- package/src/lib/model/commerce-orders.js +0 -80
- package/src/lib/model/commerce-orders.js.map +1 -1
- package/src/lib/model/index.d.ts +0 -1
- package/src/lib/model/index.js +0 -1
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/plugin.js +5 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/openapi.d.ts +27 -0
- package/src/lib/server/api-v1/openapi.js +219 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/orders-and-products.d.ts +19 -0
- package/src/lib/server/api-v1/orders-and-products.js +419 -0
- package/src/lib/server/api-v1/orders-and-products.js.map +1 -0
- package/src/lib/server/billing-webhook.js +10 -13
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/download.js +4 -1
- package/src/lib/server/download.js.map +1 -1
- package/src/lib/server/fulfill-order.d.ts +52 -11
- package/src/lib/server/fulfill-order.js +6 -9
- package/src/lib/server/fulfill-order.js.map +1 -1
- package/src/lib/server/gift-card-issue.d.ts +57 -0
- package/src/lib/server/gift-card-issue.js +155 -0
- package/src/lib/server/gift-card-issue.js.map +1 -0
- package/src/lib/server/gift-cards.js +25 -84
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/membership-register.js +9 -8
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +7 -5
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/paid-media-delivery.d.ts +21 -0
- package/src/lib/server/paid-media-delivery.js +23 -5
- package/src/lib/server/paid-media-delivery.js.map +1 -1
- package/src/lib/server/paid-media-serve-count.d.ts +28 -0
- package/src/lib/server/paid-media-serve-count.js +46 -0
- package/src/lib/server/paid-media-serve-count.js.map +1 -0
- package/src/lib/server/person-eraser.d.ts +45 -0
- package/src/lib/server/person-eraser.js +57 -0
- package/src/lib/server/person-eraser.js.map +1 -0
- package/src/lib/server/refund.js +15 -15
- package/src/lib/server/refund.js.map +1 -1
- package/src/lib/server/restock-flag.d.ts +2 -2
- package/src/lib/server/restock-flag.js +1 -1
- package/src/lib/server/restock-flag.js.map +1 -1
- package/src/lib/server/stream.js +4 -1
- package/src/lib/server/stream.js.map +1 -1
- package/src/lib/server.js +5 -21
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-person-records.d.ts +43 -0
- package/src/lib/testing/stand-in-person-records.js +63 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/transfer/gift-card-confirm-step.component.d.ts +22 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js +259 -0
- package/src/lib/transfer/gift-card-confirm-step.component.js.map +1 -0
- package/src/lib/transfer/gift-cards-plan.d.ts +29 -0
- package/src/lib/transfer/gift-cards-plan.js +40 -0
- package/src/lib/transfer/gift-cards-plan.js.map +1 -0
- package/src/lib/transfer/gift-cards.server.d.ts +34 -0
- package/src/lib/transfer/gift-cards.server.js +296 -0
- package/src/lib/transfer/gift-cards.server.js.map +1 -0
- package/src/lib/transfer/list-filter.d.ts +29 -0
- package/src/lib/transfer/list-filter.js +112 -0
- package/src/lib/transfer/list-filter.js.map +1 -0
- package/src/lib/transfer/product-import-results.d.ts +29 -0
- package/src/lib/transfer/product-import-results.js +49 -0
- package/src/lib/transfer/product-import-results.js.map +1 -0
- package/src/lib/transfer/product-import-step.component.d.ts +30 -0
- package/src/lib/transfer/product-import-step.component.js +99 -0
- package/src/lib/transfer/product-import-step.component.js.map +1 -0
- package/src/lib/transfer/product-transfer.d.ts +211 -0
- package/src/lib/transfer/product-transfer.js +1594 -0
- package/src/lib/transfer/product-transfer.js.map +1 -0
- package/src/lib/transfer/products.server.d.ts +44 -0
- package/src/lib/transfer/products.server.js +550 -0
- package/src/lib/transfer/products.server.js.map +1 -0
- package/src/lib/transfer/records-transfer.d.ts +182 -0
- package/src/lib/transfer/records-transfer.js +1153 -0
- package/src/lib/transfer/records-transfer.js.map +1 -0
- package/src/lib/transfer/records.server.d.ts +68 -0
- package/src/lib/transfer/records.server.js +455 -0
- package/src/lib/transfer/records.server.js.map +1 -0
- package/src/lib/transfer/register-transfer-resources.d.ts +17 -0
- package/src/lib/transfer/register-transfer-resources.js +171 -0
- package/src/lib/transfer/register-transfer-resources.js.map +1 -0
- package/src/lib/transfer/register-transfer-ui.d.ts +23 -0
- package/src/lib/transfer/register-transfer-ui.js +78 -0
- package/src/lib/transfer/register-transfer-ui.js.map +1 -0
- package/src/lib/transfer/server-common.d.ts +76 -0
- package/src/lib/transfer/server-common.js +165 -0
- package/src/lib/transfer/server-common.js.map +1 -0
- package/src/lib/transfer/transfer-header-actions.component.d.ts +39 -0
- package/src/lib/transfer/transfer-header-actions.component.js +79 -0
- package/src/lib/transfer/transfer-header-actions.component.js.map +1 -0
- package/src/lib/transfer/transfer-keys.d.ts +31 -0
- package/src/lib/transfer/transfer-keys.js +56 -0
- package/src/lib/transfer/transfer-keys.js.map +1 -0
- package/src/lib/model/commerce-io.d.ts +0 -36
- package/src/lib/model/commerce-io.js +0 -245
- package/src/lib/model/commerce-io.js.map +0 -1
- package/src/lib/server/contact-refund.d.ts +0 -117
- package/src/lib/server/contact-refund.js +0 -196
- package/src/lib/server/contact-refund.js.map +0 -1
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
1
|
/**
|
|
3
2
|
* @license
|
|
4
3
|
* Copyright 2026 Aglyn LLC
|
|
@@ -15,14 +14,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
14
|
* See the License for the specific language governing permissions and
|
|
16
15
|
* limitations under the License.
|
|
17
16
|
*/ import * as Aglyn from "@aglyn/aglyn/server";
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import { firebaseAdmin, getOrgForHost, hostSendingIdentity, meterHostEmail, renderHostEmailWithTokens } from "@aglyn/tenant-data-admin";
|
|
21
|
-
import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
22
|
-
/** A merchant may not mint more than this in one card. */ const MAX_ISSUE_CENTS = 100000;
|
|
23
|
-
/** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` already mints. */ function mintCode() {
|
|
24
|
-
return `GC-${randomBytes(6).toString('hex').toUpperCase()}`;
|
|
25
|
-
}
|
|
17
|
+
import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { GiftCardIssueRefusal, issueGiftCard } from "./gift-card-issue.js";
|
|
26
19
|
/**
|
|
27
20
|
* Gift-card issue / void (AGL-2226).
|
|
28
21
|
*
|
|
@@ -70,7 +63,7 @@ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
|
70
63
|
});
|
|
71
64
|
}
|
|
72
65
|
try {
|
|
73
|
-
var _hostSnapshot_get, _body_amountCents, _body_recipientEmail, _body_note;
|
|
66
|
+
var _hostSnapshot_get, _body_amountCents, _body_recipientEmail, _body_note, _decoded_email;
|
|
74
67
|
const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
|
|
75
68
|
const firestore = firebaseAdmin.app().firestore();
|
|
76
69
|
const hostRef = firestore.collection('hosts').doc(hostId);
|
|
@@ -174,86 +167,34 @@ import { isEmailConfigured, sendEmail } from "@aglyn/shared-util-email";
|
|
|
174
167
|
code
|
|
175
168
|
});
|
|
176
169
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
recipientEmail: recipientEmail || null,
|
|
195
|
-
// Null rather than absent: the field is what tells a reader whether a
|
|
196
|
-
// card came from a sale or from the console, and `undefined` would
|
|
197
|
-
// make an issued-by-hand card indistinguishable from a legacy row.
|
|
198
|
-
orderId: null,
|
|
199
|
-
issuedBy: decoded.uid
|
|
200
|
-
}, note ? {
|
|
201
|
-
note
|
|
202
|
-
} : {}, {
|
|
203
|
-
// What the console's Gift cards search asks (AGL-3321).
|
|
204
|
-
searchTokens: giftCardSearchTokens(code, recipientEmail || null),
|
|
205
|
-
createdAtMs: Date.now()
|
|
206
|
-
}));
|
|
207
|
-
let emailed = false;
|
|
208
|
-
if (recipientEmail && isEmailConfigured()) {
|
|
209
|
-
var _ref;
|
|
210
|
-
const value = `$${(amountCents / 100).toFixed(2)}`;
|
|
211
|
-
// The same site-designed template the purchase path uses (AGL-771), so
|
|
212
|
-
// a hand-issued card arrives looking like a bought one — with the
|
|
213
|
-
// merchant's note (AGL-3432). A card sent by hand often goes to someone
|
|
214
|
-
// who has never visited the site, and the note is the only part of the
|
|
215
|
-
// message the merchant wrote.
|
|
216
|
-
const designed = await renderHostEmailWithTokens(firestore, hostId, 'gift-card', {
|
|
217
|
-
'giftcard.code': code,
|
|
218
|
-
'giftcard.value': value,
|
|
219
|
-
'giftcard.note': note
|
|
220
|
-
});
|
|
221
|
-
const sent = await sendEmail(_extends({
|
|
222
|
-
to: recipientEmail,
|
|
223
|
-
subject: (_ref = designed == null ? void 0 : designed.subject) != null ? _ref : 'Your gift card',
|
|
224
|
-
text: (designed == null ? void 0 : designed.text) || (note ? `${note}\n\n` : '') + `Gift card code: ${code}\nValue: ${value}\n\n` + 'Enter it at checkout to apply the balance.'
|
|
225
|
-
}, (designed == null ? void 0 : designed.html) ? {
|
|
226
|
-
html: designed.html
|
|
227
|
-
} : {}, {
|
|
228
|
-
fromName: Aglyn.resolveBrandingProfile(owner == null ? void 0 : owner.org).fromName,
|
|
229
|
-
sendingIdentity: await hostSendingIdentity(hostId),
|
|
230
|
-
audience: 'tenant',
|
|
231
|
-
context: 'gift card',
|
|
232
|
-
// Owed to the recipient by their own order: the phishing
|
|
233
|
-
// screen's soft rules never hold it (AGL-3356).
|
|
234
|
-
owedFor: 'order'
|
|
235
|
-
})).then(()=>true).catch(()=>false);
|
|
236
|
-
if (sent) {
|
|
237
|
-
// Cost meter (AGL-1438). Transactional: the card has already been
|
|
238
|
-
// minted and the recipient is owed the code, so a quota may count
|
|
239
|
-
// this send but never refuse it. Metered on the SUCCESS path only,
|
|
240
|
-
// matching every other sender — a send the provider rejected costs
|
|
241
|
-
// nothing, and a meter that counted it would inflate the COGS figure
|
|
242
|
-
// exactly when mail was broken.
|
|
243
|
-
await meterHostEmail(hostId);
|
|
244
|
-
}
|
|
245
|
-
// The truth, not the intent: the console shows this back to whoever
|
|
246
|
-
// issued the card, and reporting a delivered email for a send that
|
|
247
|
-
// threw is how a customer ends up waiting for a code that is not
|
|
248
|
-
// coming.
|
|
249
|
-
emailed = sent;
|
|
250
|
-
}
|
|
170
|
+
// Issued through the one issue path the gift card import uses too
|
|
171
|
+
// (AGL-3551): the amount bounded, the code minted and never overwritten,
|
|
172
|
+
// the activity line written, and the recipient emailed.
|
|
173
|
+
const issued = await issueGiftCard({
|
|
174
|
+
firestore,
|
|
175
|
+
hostId,
|
|
176
|
+
amountCents: Number((_body_amountCents = body.amountCents) != null ? _body_amountCents : 0),
|
|
177
|
+
recipientEmail: String((_body_recipientEmail = body.recipientEmail) != null ? _body_recipientEmail : ''),
|
|
178
|
+
note: String((_body_note = body.note) != null ? _body_note : ''),
|
|
179
|
+
issuer: {
|
|
180
|
+
uid: decoded.uid,
|
|
181
|
+
email: (_decoded_email = decoded.email) != null ? _decoded_email : null
|
|
182
|
+
},
|
|
183
|
+
email: true,
|
|
184
|
+
ownerOrg: owner == null ? void 0 : owner.org
|
|
185
|
+
});
|
|
186
|
+
const { code, emailed } = issued;
|
|
251
187
|
return res.status(200).json({
|
|
252
188
|
ok: true,
|
|
253
189
|
code,
|
|
254
190
|
emailed
|
|
255
191
|
});
|
|
256
192
|
} catch (error) {
|
|
193
|
+
if (error instanceof GiftCardIssueRefusal) {
|
|
194
|
+
return res.status(error.status).json({
|
|
195
|
+
error: error.message
|
|
196
|
+
});
|
|
197
|
+
}
|
|
257
198
|
console.error(error);
|
|
258
199
|
return res.status(500).json({
|
|
259
200
|
error: 'Gift card operation failed'
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-cards.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 { randomBytes } from 'crypto'\nimport { giftCardSearchTokens } from '../model/gift-card-search'\nimport {\n firebaseAdmin,\n getOrgForHost,\n hostSendingIdentity,\n meterHostEmail,\n renderHostEmailWithTokens,\n} from '@aglyn/tenant-data-admin'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\n\n/** A merchant may not mint more than this in one card. */\nconst MAX_ISSUE_CENTS = 100_000\n\n/** `GC-` + 12 uppercase hex, the shape `billing-webhook.ts` already mints. */\nfunction mintCode(): string {\n return `GC-${randomBytes(6).toString('hex').toUpperCase()}`\n}\n\n/**\n * Gift-card issue / void (AGL-2226).\n *\n * The `giftCards` subcollection has existed since AGL-322 — minted by\n * `billing-webhook.ts` when a gift-card product is bought, decremented by\n * `cart-checkout.ts` at redemption. Neither end had a console: grepping for\n * that collection returned exactly those two server files, so a\n * merchant on Business (the tier that carries `giftCards`) could sell store\n * credit and then not see one cent of the liability they had taken on, help\n * a customer who lost a code, or void a leaked one.\n *\n * WRITES ARE SERVER-SIDE, and that is not a style preference. The Firestore\n * rules' host catch-all would happily grant an admin `create`/`update` on\n * `giftCards` — it is not in any exclusion list. A client-writable\n * `balanceCents` is a self-service discount machine: set it to any number,\n * redeem it at checkout, and `cart-checkout.ts` applies it as amount-off\n * against a Stripe coupon. The reads stay client-side (the catch-all's\n * `allow read` already grants them, and the card wants live updates), so\n * only the two operations that move money come through here.\n *\n * Re-checks the entitlement server-side for the same reason\n * `billing-webhook.ts:2285` does: a card issued while the plan was live is\n * honoured, but a lapsed plan must not mint new ones.\n */\nexport const giftCardsHandler: 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\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const action = String(body.action ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (action !== 'issue' && action !== 'void' && action !== 'release') {\n return res.status(400).json({ error: 'Unknown action' })\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 if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // AN ALLOWLIST (AGL-2372), not the `!role || role === 'viewer'` denylist\n // this route used to carry — the same correction AGL-2262 made to\n // `pos-order.ts` and `draft-order.ts` and did not reach here.\n //\n // A denylist silently WIDENS every time the role union grows. It did:\n // `HostAccessRole` is `admin | editor | author | viewer` since AGL-2334,\n // and `author` is grantable on any host through `/api/hosts/members`. So\n // \"not literally viewer\" started admitting authors — a role that exists to\n // edit content — to a route that mints and destroys store credit.\n //\n // `admin | editor` is the bar this route always meant (the comment here\n // said \"owner/admin/editor, not viewer\"); the difference is that a role\n // added tomorrow is now refused until someone decides otherwise, rather\n // than admitted by silence. Absent stays refused: `undefined` is not in\n // the set, which matters with `strictNullChecks` off.\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 owner = await getOrgForHost(hostId).catch(() => null)\n if (!Aglyn.checkEntitlement(owner?.org as never, 'giftCards')) {\n return res\n .status(402)\n .json({ error: 'Gift cards are not included on this plan' })\n }\n\n // RELEASE A HELD CARD (AGL-3363). A fraud warning, review or dispute on\n // the payment that bought a card freezes it; the merchant who has\n // satisfied themselves the payment is genuine lifts that here. A voided\n // card stays voided: release restores redemption, never a balance.\n if (action === 'release') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n if (!(Number(existing.get('frozenAtMs')) > 0)) {\n return res.status(409).json({ error: 'This gift card is not on hold' })\n }\n await cardRef.set(\n {\n frozenAtMs: null,\n frozenReason: null,\n releasedAtMs: Date.now(),\n releasedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n if (action === 'void') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n // Zero the balance rather than delete the document. AGL-1767 is the\n // reason: a deleted card that a checkout still had in flight was\n // RESURRECTED by the webhook's `increment(-cents)` into a NEGATIVE\n // balance, which then read as a card with money on it and understated\n // the outstanding total. A zeroed card decrements to a negative number\n // that no redemption path will ever accept, and the row stays\n // auditable.\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n await cardRef.set(\n {\n balanceCents: 0,\n voidedAtMs: Date.now(),\n voidedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n const amountCents = Math.round(Number(body.amountCents ?? 0))\n if (!Number.isFinite(amountCents) || amountCents <= 0) {\n return res.status(400).json({ error: 'Amount must be above zero' })\n }\n if (amountCents > MAX_ISSUE_CENTS) {\n return res\n .status(400)\n .json({ error: `Amount may not exceed $${MAX_ISSUE_CENTS / 100}` })\n }\n const recipientEmail = String(body.recipientEmail ?? '').trim().slice(0, 200)\n const note = String(body.note ?? '').trim().slice(0, 200)\n const code = mintCode()\n await hostRef.collection('giftCards').doc(code).set({\n initialCents: amountCents,\n balanceCents: amountCents,\n recipientEmail: recipientEmail || null,\n // Null rather than absent: the field is what tells a reader whether a\n // card came from a sale or from the console, and `undefined` would\n // make an issued-by-hand card indistinguishable from a legacy row.\n orderId: null,\n issuedBy: decoded.uid,\n ...(note ? { note } : {}),\n // What the console's Gift cards search asks (AGL-3321).\n searchTokens: giftCardSearchTokens(code, recipientEmail || null),\n createdAtMs: Date.now(),\n })\n\n let emailed = false\n if (recipientEmail && isEmailConfigured()) {\n const value = `$${(amountCents / 100).toFixed(2)}`\n // The same site-designed template the purchase path uses (AGL-771), so\n // a hand-issued card arrives looking like a bought one — with the\n // merchant's note (AGL-3432). A card sent by hand often goes to someone\n // who has never visited the site, and the note is the only part of the\n // message the merchant wrote.\n const designed = await renderHostEmailWithTokens(\n firestore,\n hostId,\n 'gift-card',\n { 'giftcard.code': code, 'giftcard.value': value, 'giftcard.note': note },\n )\n const sent = await sendEmail({\n to: recipientEmail,\n subject: designed?.subject ?? 'Your gift card',\n text:\n designed?.text ||\n (note ? `${note}\\n\\n` : '') +\n `Gift card code: ${code}\\nValue: ${value}\\n\\n` +\n 'Enter it at checkout to apply the balance.',\n ...(designed?.html ? { html: designed.html } : {}),\n fromName: Aglyn.resolveBrandingProfile(owner?.org as never).fromName,\n sendingIdentity: await hostSendingIdentity(hostId),\n audience: 'tenant',\n context: 'gift card',\n // Owed to the recipient by their own order: the phishing\n // screen's soft rules never hold it (AGL-3356).\n owedFor: 'order',\n })\n .then(() => true)\n .catch(() => false)\n if (sent) {\n // Cost meter (AGL-1438). Transactional: the card has already been\n // minted and the recipient is owed the code, so a quota may count\n // this send but never refuse it. Metered on the SUCCESS path only,\n // matching every other sender — a send the provider rejected costs\n // nothing, and a meter that counted it would inflate the COGS figure\n // exactly when mail was broken.\n await meterHostEmail(hostId)\n }\n // The truth, not the intent: the console shows this back to whoever\n // issued the card, and reporting a delivered email for a send that\n // threw is how a customer ends up waiting for a code that is not\n // coming.\n emailed = sent\n }\n\n return res.status(200).json({ ok: true, code, emailed })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Gift card operation failed' })\n }\n}\n"],"names":["Aglyn","randomBytes","giftCardSearchTokens","firebaseAdmin","getOrgForHost","hostSendingIdentity","meterHostEmail","renderHostEmailWithTokens","isEmailConfigured","sendEmail","MAX_ISSUE_CENTS","mintCode","toString","toUpperCase","giftCardsHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","action","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","owner","catch","checkEntitlement","org","code","trim","cardRef","existing","Number","set","frozenAtMs","frozenReason","releasedAtMs","Date","now","releasedBy","merge","ok","balanceCents","voidedAtMs","voidedBy","amountCents","Math","round","isFinite","recipientEmail","note","initialCents","orderId","issuedBy","searchTokens","createdAtMs","emailed","value","toFixed","designed","sent","to","subject","text","html","fromName","resolveBrandingProfile","sendingIdentity","audience","context","owedFor","then","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/gift-cards.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 { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { type PluginApiHandler } from '@aglyn/aglyn/server'\nimport { GiftCardIssueRefusal, issueGiftCard } from './gift-card-issue'\n\n/**\n * Gift-card issue / void (AGL-2226).\n *\n * The `giftCards` subcollection has existed since AGL-322 — minted by\n * `billing-webhook.ts` when a gift-card product is bought, decremented by\n * `cart-checkout.ts` at redemption. Neither end had a console: grepping for\n * that collection returned exactly those two server files, so a\n * merchant on Business (the tier that carries `giftCards`) could sell store\n * credit and then not see one cent of the liability they had taken on, help\n * a customer who lost a code, or void a leaked one.\n *\n * WRITES ARE SERVER-SIDE, and that is not a style preference. The Firestore\n * rules' host catch-all would happily grant an admin `create`/`update` on\n * `giftCards` — it is not in any exclusion list. A client-writable\n * `balanceCents` is a self-service discount machine: set it to any number,\n * redeem it at checkout, and `cart-checkout.ts` applies it as amount-off\n * against a Stripe coupon. The reads stay client-side (the catch-all's\n * `allow read` already grants them, and the card wants live updates), so\n * only the two operations that move money come through here.\n *\n * Re-checks the entitlement server-side for the same reason\n * `billing-webhook.ts:2285` does: a card issued while the plan was live is\n * honoured, but a lapsed plan must not mint new ones.\n */\nexport const giftCardsHandler: 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\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body || '{}') : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const action = String(body.action ?? '')\n if (!hostId) return res.status(400).json({ error: 'Missing hostId' })\n if (action !== 'issue' && action !== 'void' && action !== 'release') {\n return res.status(400).json({ error: 'Unknown action' })\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 if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // AN ALLOWLIST (AGL-2372), not the `!role || role === 'viewer'` denylist\n // this route used to carry — the same correction AGL-2262 made to\n // `pos-order.ts` and `draft-order.ts` and did not reach here.\n //\n // A denylist silently WIDENS every time the role union grows. It did:\n // `HostAccessRole` is `admin | editor | author | viewer` since AGL-2334,\n // and `author` is grantable on any host through `/api/hosts/members`. So\n // \"not literally viewer\" started admitting authors — a role that exists to\n // edit content — to a route that mints and destroys store credit.\n //\n // `admin | editor` is the bar this route always meant (the comment here\n // said \"owner/admin/editor, not viewer\"); the difference is that a role\n // added tomorrow is now refused until someone decides otherwise, rather\n // than admitted by silence. Absent stays refused: `undefined` is not in\n // the set, which matters with `strictNullChecks` off.\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 owner = await getOrgForHost(hostId).catch(() => null)\n if (!Aglyn.checkEntitlement(owner?.org as never, 'giftCards')) {\n return res\n .status(402)\n .json({ error: 'Gift cards are not included on this plan' })\n }\n\n // RELEASE A HELD CARD (AGL-3363). A fraud warning, review or dispute on\n // the payment that bought a card freezes it; the merchant who has\n // satisfied themselves the payment is genuine lifts that here. A voided\n // card stays voided: release restores redemption, never a balance.\n if (action === 'release') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n if (!(Number(existing.get('frozenAtMs')) > 0)) {\n return res.status(409).json({ error: 'This gift card is not on hold' })\n }\n await cardRef.set(\n {\n frozenAtMs: null,\n frozenReason: null,\n releasedAtMs: Date.now(),\n releasedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n if (action === 'void') {\n const code = String(body.code ?? '').trim()\n if (!code) return res.status(400).json({ error: 'Missing code' })\n const cardRef = hostRef.collection('giftCards').doc(code)\n // Zero the balance rather than delete the document. AGL-1767 is the\n // reason: a deleted card that a checkout still had in flight was\n // RESURRECTED by the webhook's `increment(-cents)` into a NEGATIVE\n // balance, which then read as a card with money on it and understated\n // the outstanding total. A zeroed card decrements to a negative number\n // that no redemption path will ever accept, and the row stays\n // auditable.\n const existing = await cardRef.get()\n if (!existing.exists) {\n return res.status(404).json({ error: 'Unknown gift card' })\n }\n await cardRef.set(\n {\n balanceCents: 0,\n voidedAtMs: Date.now(),\n voidedBy: decoded.uid,\n },\n { merge: true },\n )\n return res.status(200).json({ ok: true, code })\n }\n\n // Issued through the one issue path the gift card import uses too\n // (AGL-3551): the amount bounded, the code minted and never overwritten,\n // the activity line written, and the recipient emailed.\n const issued = await issueGiftCard({\n firestore,\n hostId,\n amountCents: Number(body.amountCents ?? 0),\n recipientEmail: String(body.recipientEmail ?? ''),\n note: String(body.note ?? ''),\n issuer: { uid: decoded.uid, email: decoded.email ?? null },\n email: true,\n ownerOrg: owner?.org,\n })\n const { code, emailed } = issued\n return res.status(200).json({ ok: true, code, emailed })\n } catch (error) {\n if (error instanceof GiftCardIssueRefusal) {\n return res.status(error.status).json({ error: error.message })\n }\n console.error(error)\n return res.status(500).json({ error: 'Gift card operation failed' })\n }\n}\n"],"names":["Aglyn","firebaseAdmin","getOrgForHost","GiftCardIssueRefusal","issueGiftCard","giftCardsHandler","req","res","body","method","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","hostId","action","hostSnapshot","decoded","app","auth","verifyIdToken","firestore","hostRef","collection","doc","get","exists","memberRole","uid","owner","catch","checkEntitlement","org","code","trim","cardRef","existing","Number","set","frozenAtMs","frozenReason","releasedAtMs","Date","now","releasedBy","merge","ok","balanceCents","voidedAtMs","voidedBy","issued","amountCents","recipientEmail","note","issuer","email","ownerOrg","emailed","message","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,SAASC,aAAa,EAAEC,aAAa,QAAQ,2BAA0B;AAEvE,SAASC,oBAAoB,EAAEC,aAAa,QAAQ,uBAAmB;AAEvE;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,MAAMC,mBAAqC,OAAOC,KAAKC;QAI/BD,4BAOoCA,WAC3CE,cACAA;IAZtB,IAAIF,IAAIG,MAAM,KAAK,QAAQ;QACzB,OAAOF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;IAC5D;IACA,MAAMC,gBAAgBC,QAAOR,6BAAAA,IAAIS,OAAO,CAACF,aAAa,YAAzBP,6BAA6B;IAC1D,MAAMU,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAOT,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAkB;IAErE,MAAMJ,OACJ,OAAOF,IAAIE,IAAI,KAAK,WAAWa,KAAKC,KAAK,CAAChB,IAAIE,IAAI,IAAI,SAASF,YAAAA,IAAIE,IAAI,YAARF,YAAY,CAAC;IAC9E,MAAMiB,SAAST,QAAON,eAAAA,KAAKe,MAAM,YAAXf,eAAe;IACrC,MAAMgB,SAASV,QAAON,eAAAA,KAAKgB,MAAM,YAAXhB,eAAe;IACrC,IAAI,CAACe,QAAQ,OAAOhB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEC,OAAO;IAAiB;IACnE,IAAIY,WAAW,WAAWA,WAAW,UAAUA,WAAW,WAAW;QACnE,OAAOjB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiB;IACxD;IAEA,IAAI;YAuBkBa,mBAsEEjB,mBACGA,sBACVA,YACsBkB;QA/FrC,MAAMA,UAAU,MAAMzB,cAAc0B,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACb;QAC/D,MAAMc,YAAY7B,cAAc0B,GAAG,GAAGG,SAAS;QAC/C,MAAMC,UAAUD,UAAUE,UAAU,CAAC,SAASC,GAAG,CAACV;QAClD,MAAME,eAAe,MAAMM,QAAQG,GAAG;QACtC,IAAI,CAACT,aAAaU,MAAM,EAAE;YACxB,OAAO5B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;QACtD;QACA,yEAAyE;QACzE,kEAAkE;QAClE,8DAA8D;QAC9D,EAAE;QACF,sEAAsE;QACtE,yEAAyE;QACzE,yEAAyE;QACzE,2EAA2E;QAC3E,kEAAkE;QAClE,EAAE;QACF,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,sDAAsD;QACtD,MAAMwB,aAAa,EAACX,oBAAAA,aAAaS,GAAG,CAAC,0BAAjBT,oBAAmC,CAAC,EAAE,CAACC,QAAQW,GAAG,CAAC;QACvE,IAAID,eAAe,WAAWA,eAAe,UAAU;YACrD,OAAO7B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAgB;QACvD;QACA,MAAM0B,QAAQ,MAAMpC,cAAcqB,QAAQgB,KAAK,CAAC,IAAM;QACtD,IAAI,CAACvC,MAAMwC,gBAAgB,CAACF,yBAAAA,MAAOG,GAAG,EAAW,cAAc;YAC7D,OAAOlC,IACJG,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEC,OAAO;YAA2C;QAC9D;QAEA,wEAAwE;QACxE,kEAAkE;QAClE,wEAAwE;QACxE,mEAAmE;QACnE,IAAIY,WAAW,WAAW;gBACJhB;YAApB,MAAMkC,OAAO5B,QAAON,aAAAA,KAAKkC,IAAI,YAATlC,aAAa,IAAImC,IAAI;YACzC,IAAI,CAACD,MAAM,OAAOnC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC/D,MAAMgC,UAAUb,QAAQC,UAAU,CAAC,aAAaC,GAAG,CAACS;YACpD,MAAMG,WAAW,MAAMD,QAAQV,GAAG;YAClC,IAAI,CAACW,SAASV,MAAM,EAAE;gBACpB,OAAO5B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAAoB;YAC3D;YACA,IAAI,CAAEkC,CAAAA,OAAOD,SAASX,GAAG,CAAC,iBAAiB,CAAA,GAAI;gBAC7C,OAAO3B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAAgC;YACvE;YACA,MAAMgC,QAAQG,GAAG,CACf;gBACEC,YAAY;gBACZC,cAAc;gBACdC,cAAcC,KAAKC,GAAG;gBACtBC,YAAY3B,QAAQW,GAAG;YACzB,GACA;gBAAEiB,OAAO;YAAK;YAEhB,OAAO/C,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAE4C,IAAI;gBAAMb;YAAK;QAC/C;QAEA,IAAIlB,WAAW,QAAQ;gBACDhB;YAApB,MAAMkC,OAAO5B,QAAON,cAAAA,KAAKkC,IAAI,YAATlC,cAAa,IAAImC,IAAI;YACzC,IAAI,CAACD,MAAM,OAAOnC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC/D,MAAMgC,UAAUb,QAAQC,UAAU,CAAC,aAAaC,GAAG,CAACS;YACpD,oEAAoE;YACpE,iEAAiE;YACjE,mEAAmE;YACnE,sEAAsE;YACtE,uEAAuE;YACvE,8DAA8D;YAC9D,aAAa;YACb,MAAMG,WAAW,MAAMD,QAAQV,GAAG;YAClC,IAAI,CAACW,SAASV,MAAM,EAAE;gBACpB,OAAO5B,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAAoB;YAC3D;YACA,MAAMgC,QAAQG,GAAG,CACf;gBACES,cAAc;gBACdC,YAAYN,KAAKC,GAAG;gBACpBM,UAAUhC,QAAQW,GAAG;YACvB,GACA;gBAAEiB,OAAO;YAAK;YAEhB,OAAO/C,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAE4C,IAAI;gBAAMb;YAAK;QAC/C;QAEA,kEAAkE;QAClE,yEAAyE;QACzE,wDAAwD;QACxD,MAAMiB,SAAS,MAAMvD,cAAc;YACjC0B;YACAP;YACAqC,aAAad,QAAOtC,oBAAAA,KAAKoD,WAAW,YAAhBpD,oBAAoB;YACxCqD,gBAAgB/C,QAAON,uBAAAA,KAAKqD,cAAc,YAAnBrD,uBAAuB;YAC9CsD,MAAMhD,QAAON,aAAAA,KAAKsD,IAAI,YAATtD,aAAa;YAC1BuD,QAAQ;gBAAE1B,KAAKX,QAAQW,GAAG;gBAAE2B,KAAK,GAAEtC,iBAAAA,QAAQsC,KAAK,YAAbtC,iBAAiB;YAAK;YACzDsC,OAAO;YACPC,QAAQ,EAAE3B,yBAAAA,MAAOG,GAAG;QACtB;QACA,MAAM,EAAEC,IAAI,EAAEwB,OAAO,EAAE,GAAGP;QAC1B,OAAOpD,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAE4C,IAAI;YAAMb;YAAMwB;QAAQ;IACxD,EAAE,OAAOtD,OAAO;QACd,IAAIA,iBAAiBT,sBAAsB;YACzC,OAAOI,IAAIG,MAAM,CAACE,MAAMF,MAAM,EAAEC,IAAI,CAAC;gBAAEC,OAAOA,MAAMuD,OAAO;YAAC;QAC9D;QACAC,QAAQxD,KAAK,CAACA;QACd,OAAOL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA6B;IACpE;AACF,EAAC"}
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { memberNameSearchFields, memberSearchTokens } from "./member-name-search.js";
|
|
18
18
|
import { checkVisitorRecordCeiling, HOST_TOKENS, marketingConsentFieldsForHost, SITE_MEMBER_CEILING_CODE, SITE_MEMBER_UNAVAILABLE_MESSAGE, SITE_MEMBERS_MAX_PER_HOST } from "@aglyn/aglyn/server";
|
|
19
|
-
import { firebaseAdmin, rearmVisitorRecordCeilingNotice, recordVisitorRecordCeilingTrip
|
|
19
|
+
import { firebaseAdmin, rearmVisitorRecordCeilingNotice, recordVisitorRecordCeilingTrip } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { emitHostEvent } from "@aglyn/tenant-runtime";
|
|
21
21
|
import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
|
|
22
|
+
import { CONVERSION_TOUCH_DETAIL, resolveConversionTouch } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
|
|
22
23
|
import { hashMemberPassword, mintMemberSession, setMemberCookie } from "./membership.js";
|
|
23
24
|
import { memberCredentialRef } from "./member-credentials.js";
|
|
24
25
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -189,18 +190,18 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
189
190
|
// never fails the sign-up: the visitor asked for an account, not for a
|
|
190
191
|
// lead record, and the trip is recorded for the owner either way.
|
|
191
192
|
/*
|
|
192
|
-
* THE
|
|
193
|
+
* WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SIGN-UP.
|
|
193
194
|
*
|
|
194
195
|
* A sign-up is the identify moment for a visitor who has been anonymous
|
|
195
|
-
* until now, and it writes two records
|
|
196
|
-
*
|
|
197
|
-
* two cannot end up
|
|
196
|
+
* until now, and it writes two records an arrival can be credited with —
|
|
197
|
+
* the lead and the contact. One resolve, by the plugin that credits
|
|
198
|
+
* outcomes, and the two cannot end up credited to different arrivals.
|
|
198
199
|
*
|
|
199
200
|
* The MEMBER record itself is not attributed. A member is an account the
|
|
200
201
|
* visitor holds and the lead is the site's record of the same act, so
|
|
201
202
|
* crediting both would count one sign-up twice under two names.
|
|
202
203
|
*/ const signedUpAtMs = Date.now();
|
|
203
|
-
const
|
|
204
|
+
const arrival = await resolveConversionTouch({
|
|
204
205
|
hostId,
|
|
205
206
|
wire: (_req_body5 = req.body) == null ? void 0 : _req_body5.campaignTouch,
|
|
206
207
|
email,
|
|
@@ -241,9 +242,9 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
241
242
|
lifecycleFloor: 'subscriber'
|
|
242
243
|
}, marketingConsent ? {
|
|
243
244
|
marketingConsent: true
|
|
244
|
-
} : {},
|
|
245
|
+
} : {}, arrival ? {
|
|
245
246
|
detail: {
|
|
246
|
-
|
|
247
|
+
[CONVERSION_TOUCH_DETAIL]: arrival
|
|
247
248
|
}
|
|
248
249
|
} : {}));
|
|
249
250
|
// Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n rearmVisitorRecordCeilingNotice,\n recordVisitorRecordCeilingTrip,\n resolveCampaignTouch,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n // The count the new account was judged against, for the notice re-arm.\n let usedBeforeCreate = 0\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n usedBeforeCreate = used\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // The account took a slot below the ceiling. When it took the last one,\n // the next refusal is a new crossing and is announced again (AGL-3442).\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'siteMembers',\n used: usedBeforeCreate,\n ceiling: SITE_MEMBERS_MAX_PER_HOST,\n })\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * THE CAMPAIGN TOUCH, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records that a campaign can be credited\n * with — the lead and the contact. One resolve, one keyed read, and the\n * two cannot end up naming different campaigns.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(\n hostId,\n 'memberSignUp',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","rearmVisitorRecordCeilingNotice","recordVisitorRecordCeilingTrip","resolveCampaignTouch","emitHostEvent","recordCapturedContact","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","usedBeforeCreate","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","campaignTouch","wire","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","actor","ok","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/membership-register.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { memberNameSearchFields, memberSearchTokens } from './member-name-search'\nimport {\n checkVisitorRecordCeiling,\n HOST_TOKENS,\n marketingConsentFieldsForHost,\n SITE_MEMBER_CEILING_CODE,\n SITE_MEMBER_UNAVAILABLE_MESSAGE,\n SITE_MEMBERS_MAX_PER_HOST,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n rearmVisitorRecordCeilingNotice,\n recordVisitorRecordCeilingTrip,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n CONVERSION_TOUCH_DETAIL,\n resolveConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport {\n hashMemberPassword,\n mintMemberSession,\n setMemberCookie,\n} from './membership'\nimport { memberCredentialRef } from './member-credentials'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/**\n * Site member sign-up (AGL-109): creates the member record and its credential\n * document (the scrypt hash, AGL-3308), doubles as a lead, and signs the\n * visitor in via the session cookie.\n */\nexport const membershipRegisterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const hostId = String(req.body?.hostId ?? '')\n const email = String(req.body?.email ?? '')\n .trim()\n .toLowerCase()\n const password = String(req.body?.password ?? '')\n const displayName = String(req.body?.displayName ?? '')\n .trim()\n .slice(0, 80)\n // Explicit opt-in checkbox (AGL-2499) — never inferred from signing up.\n const marketingConsent = req.body?.marketingConsent === true\n if (!hostId) return res.status(400).json({ error: 'Missing host' })\n if (!EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n if (password.length < 8) {\n return res\n .status(400)\n .json({ error: 'Password must be at least 8 characters' })\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(hostId)\n const hostSnapshot = await hostRef.get()\n if (!hostSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown site' })\n }\n // Member accounts are UNLIMITED on every plan (AGL-889) — no seat or\n // quota check belongs here. Audience monetization happens downstream:\n // contact bands meter the CRM projection (AGL-890) and paid\n // memberships carry the plan's digital transaction fee (AGL-892).\n //\n // `SITE_MEMBERS_MAX_PER_HOST` below is NOT that check and does not make\n // it one (AGL-1529). It is a flat PLATFORM ceiling — same number on every\n // plan, no `OrgEntitlements` key, nothing on the price list — and it\n // exists because this handler is reachable by an ANONYMOUS VISITOR, so\n // without it the collection was bounded only by a per-(host, IP) rate\n // limiter that fails soft and bounds the RATE rather than the TOTAL. An\n // abuse control is not something we sell; \"unlimited member accounts on\n // every plan\" stays literally true.\n const membersRef = hostRef.collection('siteMembers')\n const memberRef = membersRef.doc()\n /*\n * COUNT, DEDUPE AND CREATE IN ONE TRANSACTION (AGL-1529, the AGL-2231\n * treatment).\n *\n * Read-then-decide-then-`set()` is the create-time quota that laundered\n * everywhere else in this repo: N concurrent sign-ups each read the same\n * pre-count, each find room, and each land — and nothing re-counts\n * afterwards, so the extra accounts are permanent. The fix is WHEN the\n * count is evaluated, not the counting rule.\n * `Transaction.get(AggregateQuery)` serialises the count against a\n * concurrent create into this collection: the loser retries, re-reads the\n * higher count and is refused. The duplicate-email read moves inside for\n * free, which also closes the smaller race it always had — two\n * simultaneous sign-ups on one address both saw `empty` and both wrote.\n *\n * ALL READS BEFORE THE WRITE, which Firestore requires.\n *\n * The scrypt hash sits between the last read and the create ON PURPOSE.\n * It is ~100 ms of CPU and it is the most expensive thing in the request,\n * so a refused sign-up must never pay it — hashing before the transaction\n * would hand a flood a CPU amplifier on exactly the path the ceiling\n * exists to contain, and would also be a regression, since today a\n * duplicate-email refusal does not hash either. It is a pure computation,\n * not an effect, so it does not violate the \"nothing happens in a\n * transaction body\" rule the sibling routes state.\n *\n * A refusal is returned as DATA and rendered outside: a body that can run\n * several times must not be the place a response is built.\n */\n // The count the new account was judged against, for the notice re-arm.\n let usedBeforeCreate = 0\n const refusal = await firestore.runTransaction(async (tx) => {\n const existing = await tx.get(membersRef.where('email', '==', email).limit(1))\n if (!existing.empty) return { duplicate: true, ceiling: 0 }\n const used = (await tx.get(membersRef.count())).data().count\n usedBeforeCreate = used\n // Live documents only, so removing a member in the inbox frees the slot.\n const verdict = checkVisitorRecordCeiling(used, SITE_MEMBERS_MAX_PER_HOST)\n if (verdict.exceeded) return { duplicate: false, ceiling: verdict.ceiling }\n // The hash goes to the credential document, which no client can read\n // (AGL-3308), and in this transaction, so a member never exists without\n // the password they chose. The profile below is what the console lists.\n tx.set(memberCredentialRef(hostRef, memberRef.id), {\n passwordScrypt: hashMemberPassword(password),\n })\n tx.create(memberRef, {\n email,\n ...(displayName ? memberNameSearchFields(displayName) : {}),\n // The Site users list's search: the name and the address (AGL-3321).\n searchTokens: memberSearchTokens(displayName, email),\n /*\n * Stored `false`, not left out: the console's Status filter asks\n * `suspended == false` for the active members, and a query cannot\n * find a document that lacks the field. The drawer's Suspend and\n * Reactivate write `true` and `false` over it.\n */\n suspended: false,\n /*\n * The checkbox is PERSISTED on the member, not only forwarded.\n *\n * It reached the lead and the contact from the two lines below and\n * was dropped for this document, so `hosts/{hostId}/siteMembers` had\n * no consent field of any kind and `audience: 'members'` had nothing\n * for the send-time join to read — the audience could not be filtered\n * even in principle (`docs/specs/email-overhaul.md` §1d/§3f). The\n * other two documents are not a substitute: a member is deduped in\n * this transaction while leads append every time, and contacts are\n * ORG-scoped where a member is the site's own.\n *\n * Written only when ticked. Signing up is not opting in — that is why\n * the checkbox exists and why it defaults unchecked — so the omitted\n * case stores nothing and reads back as an unrecorded basis rather\n * than as a refusal.\n *\n * Under this site's key even though `siteMembers` already lives\n * beneath it, so that one reader answers for every silo — see\n * `addHostLead`, which makes the same write for the same reason.\n */\n ...(marketingConsent\n ? marketingConsentFieldsForHost(hostId, Date.now())\n : {}),\n createdAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return null\n })\n if (refusal?.duplicate) {\n return res.status(409).json({ error: 'That email is already a member' })\n }\n if (refusal) {\n // Visible to the HOST — the rule that a control nobody can see in\n // the console did not ship. Durable counter + one notification.\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'siteMembers',\n ceiling: refusal.ceiling,\n })\n // Opaque to the VISITOR, who is a stranger to this site and not our\n // customer (AGL-1666's rules, restated in the message's own docblock).\n // The one genuinely useful thing to hand them is a door that still\n // opens, so the site's OWN published support address rides along when\n // it has one — read through the host-token registry, whose description\n // of this field is literally \"where visitors should write for help\".\n // Nothing else off the host document may leave the console.\n const contact = HOST_TOKENS['supportEmail']?.resolve(\n hostSnapshot.data() as any,\n )\n return res.status(429).json({\n error: SITE_MEMBER_UNAVAILABLE_MESSAGE,\n // Machine-readable, because the dispatcher's rate limiter answers 429\n // too and the status therefore discriminates nothing.\n code: SITE_MEMBER_CEILING_CODE,\n ...(contact ? { contact } : {}),\n })\n }\n // The account took a slot below the ceiling. When it took the last one,\n // the next refusal is a new crossing and is announced again (AGL-3442).\n await rearmVisitorRecordCeilingNotice({\n hostRef,\n kind: 'siteMembers',\n used: usedBeforeCreate,\n ceiling: SITE_MEMBERS_MAX_PER_HOST,\n })\n // Sign-ups double as leads for the site owner (AGL-109), through the one\n // writer that enforces `LEADS_MAX_PER_HOST` (AGL-1529). A refused lead\n // never fails the sign-up: the visitor asked for an account, not for a\n // lead record, and the trip is recorded for the owner either way.\n /*\n * WHERE THE VISITOR ARRIVED FROM, RESOLVED ONCE FOR THE WHOLE SIGN-UP.\n *\n * A sign-up is the identify moment for a visitor who has been anonymous\n * until now, and it writes two records an arrival can be credited with —\n * the lead and the contact. One resolve, by the plugin that credits\n * outcomes, and the two cannot end up credited to different arrivals.\n *\n * The MEMBER record itself is not attributed. A member is an account the\n * visitor holds and the lead is the site's record of the same act, so\n * crediting both would count one sign-up twice under two names.\n */\n const signedUpAtMs = Date.now()\n const arrival = await resolveConversionTouch({\n hostId,\n wire: req.body?.campaignTouch,\n email,\n atMs: signedUpAtMs,\n })\n /*\n * Contacts ingestion (AGL-197), reported to whichever plugin keeps people\n * rather than written by this one (AGL-3080). Commerce knows it just met\n * somebody; what a person record is belongs to the plugin that models it,\n * and a workspace that keeps none is a quiet, correct answer here.\n */\n /*\n * A member account is a RELATIONSHIP (AGL-3232): the person is a\n * contact from this moment, and an open lead the site held for the\n * address is closed as converted onto that contact by the record\n * system — a sign-up files no lead of its own any more, the way a\n * portal user is a contact and not a lead in Salesforce.\n */\n void recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email, name: displayName || undefined },\n interaction: {\n source: 'member',\n refId: memberRef.id,\n summary: 'Joined as a member',\n },\n surface: 'relationship',\n // An account is a subscription to the site, not an enquiry (AGL-2612):\n // the stage on the contact says only that the person asked to be\n // kept. A floor, so a customer who opens an account stays a customer.\n lifecycleFloor: 'subscriber',\n ...(marketingConsent ? { marketingConsent: true } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(arrival ? { detail: { [CONVERSION_TOUCH_DETAIL]: arrival } } : {}),\n })\n // Event trigger (AGL-128/148). A sign-up is no longer a lead (AGL-3232),\n // so the `lead` event is the lead surfaces' to emit.\n await emitHostEvent(\n hostId,\n 'memberSignUp',\n { email },\n { actor: { kind: 'visitor', email } },\n )\n setMemberCookie(res, hostId, mintMemberSession(hostId, memberRef.id))\n return res.status(200).json({ ok: true })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Sign-up failed' })\n }\n}\n"],"names":["memberNameSearchFields","memberSearchTokens","checkVisitorRecordCeiling","HOST_TOKENS","marketingConsentFieldsForHost","SITE_MEMBER_CEILING_CODE","SITE_MEMBER_UNAVAILABLE_MESSAGE","SITE_MEMBERS_MAX_PER_HOST","firebaseAdmin","rearmVisitorRecordCeilingNotice","recordVisitorRecordCeilingTrip","emitHostEvent","recordCapturedContact","CONVERSION_TOUCH_DETAIL","resolveConversionTouch","hashMemberPassword","mintMemberSession","setMemberCookie","memberCredentialRef","EMAIL_PATTERN","membershipRegisterHandler","req","res","method","status","json","error","hostId","String","body","email","trim","toLowerCase","password","displayName","slice","marketingConsent","test","length","firestore","app","hostRef","collection","doc","hostSnapshot","get","exists","membersRef","memberRef","usedBeforeCreate","refusal","runTransaction","tx","existing","where","limit","empty","duplicate","ceiling","used","count","data","verdict","exceeded","set","id","passwordScrypt","create","searchTokens","suspended","Date","now","createdAt","FieldValue","serverTimestamp","kind","contact","resolve","code","signedUpAtMs","arrival","wire","campaignTouch","atMs","orgId","identity","name","undefined","interaction","source","refId","summary","surface","lifecycleFloor","detail","actor","ok","console"],"mappings":"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"}
|
|
@@ -18,8 +18,9 @@ import { hostPublicOrigin } from "@aglyn/aglyn/app-utils/host-naming";
|
|
|
18
18
|
import { mergeSubscriptionTopics, normalizeSubscriptionTopic, topicRequiresDoubleOptIn } from "@aglyn/aglyn/app-utils/subscription-topics";
|
|
19
19
|
import { pluginRecordIndex } from "@aglyn/aglyn/plugin-manager/plugin-record-index";
|
|
20
20
|
import { NEWSLETTER_TOPIC_ID } from "../model/commerce-topics.js";
|
|
21
|
-
import { enrollListMember, firebaseAdmin, hostSendingIdentity, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, recordPendingTopicConfirmation,
|
|
21
|
+
import { enrollListMember, firebaseAdmin, hostSendingIdentity, meterHostEmail, consentGroupForSite, orgDataCollectionForHost, recordPendingTopicConfirmation, resolveOrgIdForHost, siteRequiresDoubleOptIn } from "@aglyn/tenant-data-admin";
|
|
22
22
|
import recordCapturedContact from "@aglyn/aglyn/plugin-manager/record-captured-contact";
|
|
23
|
+
import { CONVERSION_TOUCH_DETAIL, resolveConversionTouch } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
|
|
23
24
|
import { renderHostEmailWithTokens } from "@aglyn/tenant-data-admin/server/host-email-tokens";
|
|
24
25
|
import { buildConfirmUrl } from "@aglyn/tenant-data-admin/server/email-unsubscribe-link";
|
|
25
26
|
import { sendEmail } from "@aglyn/shared-util-email";
|
|
@@ -278,8 +279,9 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
278
279
|
try {
|
|
279
280
|
// A newsletter signup is an identify moment: the visitor was anonymous
|
|
280
281
|
// while they browsed and this request is the first thing that names them.
|
|
281
|
-
//
|
|
282
|
-
|
|
282
|
+
// Where they arrived from is asked once, here, of the plugin that credits
|
|
283
|
+
// outcomes, and handed to the record the capture files.
|
|
284
|
+
const arrival = await resolveConversionTouch({
|
|
283
285
|
hostId,
|
|
284
286
|
wire: body.campaignTouch,
|
|
285
287
|
email,
|
|
@@ -319,9 +321,9 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
319
321
|
marketingConsent: true
|
|
320
322
|
}, disclosedConsentGroup ? {
|
|
321
323
|
disclosedConsentGroup
|
|
322
|
-
} : {},
|
|
324
|
+
} : {}, arrival ? {
|
|
323
325
|
detail: {
|
|
324
|
-
|
|
326
|
+
[CONVERSION_TOUCH_DETAIL]: arrival
|
|
325
327
|
}
|
|
326
328
|
} : {}));
|
|
327
329
|
/*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeSubscriptionTopics,\n normalizeSubscriptionTopic,\n topicRequiresDoubleOptIn,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { NEWSLETTER_TOPIC_ID } from '../model/commerce-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveCampaignTouch,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/*\n * The stream a newsletter signup joins is `NEWSLETTER_TOPIC_ID`, the built-in\n * id and not the default stream: somebody typing their address into a footer\n * box is asking for the newsletter, and attributing it to \"Promotions and\n * offers\" would confirm them for a stream they did not name.\n */\n\n/**\n * The record kind an org's topic catalog is read as: its keeper publishes it\n * through the core's record-index seam, facts `{ description, archived,\n * doubleOptIn? }`.\n */\nconst SUBSCRIPTION_TOPIC_KIND = 'subscriptionTopic'\n\n/** How many of an org's topics the signup reads; the catalog is a handful. */\nconst TOPIC_CATALOG_SCAN = 100\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n /**\n * The disclosure key the signup rendered beside its opt-in, as the contact\n * capture received it: the membership pools over the site's consent group\n * only when it is the group's current key (AGL-3320).\n */\n disclosedConsentGroup?: string\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n ...(options.disclosedConsentGroup\n ? { disclosedConsentGroup: options.disclosedConsentGroup }\n : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: SubscriptionTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'Newsletter'\n // The `newsletter-confirmation` site email (AGL-3370): the site's design,\n // or the built-in copy in its header and footer.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n options.hostId,\n 'newsletter-confirmation',\n { 'stream.name': stream, confirmUrl: url },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: options.email,\n subject: designed?.subject || `Confirm your subscription`,\n text:\n designed?.text ||\n `Please confirm that you want to get ${stream} emails at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n ...(designed?.html ? { html: designed.html } : {}),\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Asked of the plugin that keeps the catalog, through its record index, and\n * never read from its collection. Fails soft to the declared built-ins, like\n * the preference page's read: a site whose org cannot be resolved, or a\n * process where nothing keeps the catalog, still gets the built-in streams,\n * so the decision is made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<SubscriptionTopic[]> {\n try {\n const keeper = pluginRecordIndex(SUBSCRIPTION_TOPIC_KIND)\n const orgId = keeper ? await resolveOrgIdForHost(hostId) : null\n if (!keeper || !orgId) return mergeSubscriptionTopics(null)\n const { records } = await keeper.index.list({ orgId, limit: TOPIC_CATALOG_SCAN })\n return records\n .map((record) =>\n normalizeSubscriptionTopic(record.id, { name: record.name, ...record.facts }),\n )\n .filter((topic): topic is SubscriptionTopic => !!topic)\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeSubscriptionTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: SubscriptionTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n /*\n * The key of the consent-group sentence the signup rendered, when it\n * rendered one (AGL-3320). The opt-in reaches the site's whole consent\n * group only when this is the group's current key; a signup that shows no\n * sentence sends none and subscribes the person to this site alone.\n */\n const disclosedConsentGroup =\n typeof body.__consentGroup === 'string'\n ? body.__consentGroup.trim().slice(0, 64)\n : ''\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Both channels are asked once, here, and the later touch is credited.\n const campaignTouch = await resolveCampaignTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n /*\n * Reported to whichever plugin keeps people, not written by this one\n * (AGL-3080). A newsletter signup is commerce meeting somebody; what a\n * person record is, and whether this workspace keeps any, belongs to the\n * plugin that models them.\n *\n * Still awaited, because the confirmation below is deliberately after it.\n */\n await recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email },\n interaction: {\n source: 'newsletter',\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n // A TOUCH (AGL-3232): the opt-in lands on the lead the site is\n // working for this address when it holds one, and on the contact\n // otherwise — the consent stays with the one record the person is.\n surface: 'touch',\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet. A floor, so a customer who\n // subscribes stays a customer.\n lifecycleFloor: 'subscriber',\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(campaignTouch ? { detail: { campaignTouch } } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({\n hostId,\n listId,\n email,\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeSubscriptionTopics","normalizeSubscriptionTopic","topicRequiresDoubleOptIn","pluginRecordIndex","NEWSLETTER_TOPIC_ID","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","renderHostEmailWithTokens","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","SUBSCRIPTION_TOPIC_KIND","TOPIC_CATALOG_SCAN","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","disclosedConsentGroup","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","designed","app","firestore","confirmUrl","origin","catch","to","subject","text","html","sendingIdentity","audience","context","sent","loadTopics","keeper","orgId","records","index","list","limit","map","record","facts","filter","loadHostGate","siteDefault","snapshot","Promise","all","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","__consentGroup","slice","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","campaignTouch","wire","atMs","identity","interaction","refId","summary","surface","lifecycleFloor","detail","confirming","ok","confirmationRequired"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/commerce/src/lib/server/newsletter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginApiHandler } from '@aglyn/aglyn/server'\nimport { hostPublicOrigin } from '@aglyn/aglyn/app-utils/host-naming'\nimport {\n mergeSubscriptionTopics,\n normalizeSubscriptionTopic,\n topicRequiresDoubleOptIn,\n type SubscriptionTopic,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport { pluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { NEWSLETTER_TOPIC_ID } from '../model/commerce-topics'\nimport {\n enrollListMember,\n firebaseAdmin,\n hostSendingIdentity,\n meterHostEmail,\n consentGroupForSite,\n orgDataCollectionForHost,\n recordPendingTopicConfirmation,\n resolveOrgIdForHost,\n siteRequiresDoubleOptIn,\n} from '@aglyn/tenant-data-admin'\nimport recordCapturedContact from '@aglyn/aglyn/plugin-manager/record-captured-contact'\nimport {\n CONVERSION_TOUCH_DETAIL,\n resolveConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { renderHostEmailWithTokens } from '@aglyn/tenant-data-admin/server/host-email-tokens'\nimport { buildConfirmUrl } from '@aglyn/tenant-data-admin/server/email-unsubscribe-link'\nimport { sendEmail } from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\n\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\n/*\n * The stream a newsletter signup joins is `NEWSLETTER_TOPIC_ID`, the built-in\n * id and not the default stream: somebody typing their address into a footer\n * box is asking for the newsletter, and attributing it to \"Promotions and\n * offers\" would confirm them for a stream they did not name.\n */\n\n/**\n * The record kind an org's topic catalog is read as: its keeper publishes it\n * through the core's record-index seam, facts `{ description, archived,\n * doubleOptIn? }`.\n */\nconst SUBSCRIPTION_TOPIC_KIND = 'subscriptionTopic'\n\n/** How many of an org's topics the signup reads; the catalog is a handful. */\nconst TOPIC_CATALOG_SCAN = 100\n\n/**\n * Enrolls one address into an org list's members (AGL-2499).\n *\n * The document id comes from `enrollListMember`, which is the only writer of\n * that collection — the workflow `enrollList` step reaches it too, and when\n * the two derived their own ids the same person subscribing by both routes\n * became two members of one list.\n *\n * Best-effort and silent on any problem: list enrollment rides along with\n * the newsletter signup, which must still succeed (and still upsert the\n * contact) even when the list id is stale, was mistyped into the besigner\n * prop, or belongs to an org this host cannot resolve.\n */\nasync function enrollInList(options: {\n hostId: string\n listId: string\n email: string\n name?: string\n /** The opt-in the signup itself carries — see the call site. */\n marketingConsent?: boolean\n /**\n * The disclosure key the signup rendered beside its opt-in, as the contact\n * capture received it: the membership pools over the site's consent group\n * only when it is the group's current key (AGL-3320).\n */\n disclosedConsentGroup?: string\n}): Promise<void> {\n if (!isDocumentId(options.listId)) return\n try {\n const contactsRef = await orgDataCollectionForHost(options.hostId, 'contacts')\n const listRef = contactsRef.parent?.collection('lists').doc(options.listId)\n if (!listRef) return\n const listSnapshot = await listRef.get()\n // A stale/mistyped id must not silently CREATE a list — campaign-send's\n // `list` audience would then read a list nobody set up.\n if (!listSnapshot.exists) return\n await enrollListMember({\n listRef,\n group: await consentGroupForSite(options.hostId),\n email: options.email,\n ...(options.name ? { name: options.name } : {}),\n source: 'newsletter',\n ...(options.marketingConsent ? { marketingConsent: true } : {}),\n ...(options.disclosedConsentGroup\n ? { disclosedConsentGroup: options.disclosedConsentGroup }\n : {}),\n })\n } catch (error) {\n console.error('list enrollment failed', error)\n }\n}\n\n/**\n * DOUBLE OPT-IN, when this site asks for one\n * (`docs/specs/email-competitive-gaps.md` P8).\n *\n * A footer signup is where a confirmation belongs — ActiveCampaign's shape,\n * where forms are the thing that default to it — and it is the only capture\n * path in the product where the person is present, expecting a reply, and has\n * just typed the address themselves.\n *\n * ## The confirmation message is TRANSACTIONAL\n *\n * It carries no marketing context, so it passes no frequency cap and adds no\n * unsubscribe header: somebody who just asked to subscribe is owed the answer\n * to what they asked, and gating it behind a marketing ceiling would drop\n * exactly the message that lets them out of the quarantine. The suppression\n * lists are not consulted here either — `recordPendingTopicConfirmation`\n * refuses an address that left this stream before any message is composed,\n * which is the check that matters: a signup form must not become a way to\n * mail somebody who unsubscribed by asking them again.\n *\n * A hard-bounced address is deliberately NOT refused here, and the asymmetry\n * with the opt-out above is the point. An opt-out is a stated intention; a\n * bounce is a measurement of a mailbox at a moment, and refusing to send at\n * submission time would settle it on evidence that cannot be older than the\n * question being asked. The confirmation goes out, and if it is delivered and\n * clicked, `confirmTopicSubscription` lifts the platform bounce — the round\n * trip is what proves the record stale. A complaint or an unsubscribe is\n * never lifted by that path.\n *\n * ## Best-effort, like the enrollment beside it\n *\n * A failure to send leaves the address pending and unmailable, which is the\n * safe direction: they signed up, nothing reaches them, and signing up again\n * re-sends. The signup itself still succeeds — the contact is captured with\n * its consent record either way, and losing the capture because a confirmation\n * bounced would be a worse trade for everybody.\n *\n * @returns whether the address was put in the quarantine, so the caller can\n * decide whether to enroll them on a list yet.\n */\nasync function requestConfirmation(options: {\n hostId: string\n email: string\n topics: SubscriptionTopic[]\n /** The host document the setting was read from — see {@link loadHostGate}. */\n host: { cname?: unknown; subdomain?: unknown }\n}): Promise<boolean> {\n const topic = options.topics.find(\n (candidate) => candidate.id === NEWSLETTER_TOPIC_ID,\n )\n const { result } = await recordPendingTopicConfirmation(\n options.hostId,\n options.email,\n NEWSLETTER_TOPIC_ID,\n )\n if (result !== 'pending') return false\n try {\n const siteBase =\n hostPublicOrigin({\n cname: String(options.host.cname ?? ''),\n subdomain: String(options.host.subdomain ?? ''),\n }) ?? ''\n const url = buildConfirmUrl({\n siteBase,\n hostId: options.hostId,\n email: options.email,\n topicId: NEWSLETTER_TOPIC_ID,\n })\n // No origin or no signing secret means no link, and a confirmation\n // message with nothing to click is worse than none: the person believes\n // they have subscribed and nothing ever arrives. Said out loud rather\n // than shipped quietly, exactly as the unsubscribe seam does.\n if (!url) {\n console.warn(\n '[newsletter] confirmation not sent — no public origin or no ' +\n 'EMAIL_UNSUBSCRIBE_SECRET',\n )\n return true\n }\n const stream = topic?.name ?? 'Newsletter'\n // The `newsletter-confirmation` site email (AGL-3370): the site's design,\n // or the built-in copy in its header and footer.\n const designed = await renderHostEmailWithTokens(\n firebaseAdmin.app().firestore(),\n options.hostId,\n 'newsletter-confirmation',\n { 'stream.name': stream, confirmUrl: url },\n siteBase ? { origin: siteBase } : {},\n ).catch(() => null)\n const result = await sendEmail({\n to: options.email,\n subject: designed?.subject || `Confirm your subscription`,\n text:\n designed?.text ||\n `Please confirm that you want to get ${stream} emails at this ` +\n `address:\\n\\n${url}\\n\\nThe link works for three days. If you did ` +\n 'not sign up, ignore this message — nothing will be sent.',\n ...(designed?.html ? { html: designed.html } : {}),\n sendingIdentity: await hostSendingIdentity(options.hostId),\n audience: 'tenant',\n context: 'newsletter confirmation',\n })\n /*\n * Counted against the site, as TRANSACTIONAL.\n *\n * It is a message this site sent, so it costs what a message costs and\n * the meter has to see it. `'transactional'` is the honest class: the\n * person just typed their address into a form and is waiting for the\n * answer, which is why the message carries no marketing context and no\n * unsubscribe header either. Only a delivery is counted, so a send that\n * never left is not billed as one.\n */\n if (result.sent) await meterHostEmail(options.hostId)\n } catch (error) {\n console.error('confirmation send failed', error)\n }\n return true\n}\n\n/**\n * The site's topic catalog, for the one question the signup asks of it.\n *\n * Asked of the plugin that keeps the catalog, through its record index, and\n * never read from its collection. Fails soft to the declared built-ins, like\n * the preference page's read: a site whose org cannot be resolved, or a\n * process where nothing keeps the catalog, still gets the built-in streams,\n * so the decision is made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<SubscriptionTopic[]> {\n try {\n const keeper = pluginRecordIndex(SUBSCRIPTION_TOPIC_KIND)\n const orgId = keeper ? await resolveOrgIdForHost(hostId) : null\n if (!keeper || !orgId) return mergeSubscriptionTopics(null)\n const { records } = await keeper.index.list({ orgId, limit: TOPIC_CATALOG_SCAN })\n return records\n .map((record) =>\n normalizeSubscriptionTopic(record.id, { name: record.name, ...record.facts }),\n )\n .filter((topic): topic is SubscriptionTopic => !!topic)\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeSubscriptionTopics(null)\n }\n}\n\n/**\n * Everything the confirmation decision needs, in one round trip.\n *\n * Three reads — the host document, the org lookup and the topic catalog — and\n * the first is issued IN PARALLEL with the other two rather than after them,\n * because none of the three depends on another's answer. A signup is a person\n * waiting for a form to come back, and serialized reads on that path are\n * latency the person feels for a decision that is usually \"no\".\n *\n * The host document is returned rather than discarded: the confirmation link\n * needs the site's public origin, which is on that same document, and reading\n * it twice would be a read per signup to recover something already in hand.\n *\n * Fails soft on the site flag, which `siteRequiresDoubleOptIn` already does —\n * a default a failed read could switch ON would quarantine every new signup\n * on a site whose owner never asked for confirmations.\n */\nasync function loadHostGate(hostId: string): Promise<{\n topics: SubscriptionTopic[]\n siteDefault: boolean\n host: { cname?: unknown; subdomain?: unknown }\n}> {\n const [topics, siteDefault, snapshot] = await Promise.all([\n loadTopics(hostId),\n siteRequiresDoubleOptIn(hostId),\n firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n .catch(() => null),\n ])\n return {\n topics,\n siteDefault,\n host: {\n cname: snapshot?.get('cname'),\n subdomain: snapshot?.get('subdomain'),\n },\n }\n}\n\n// Best-effort per-instance flood damper.\nconst attemptsByIp = new Map<string, number[]>()\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * Newsletter opt-in (AGL-301): footer signups and checkout opt-ins land\n * in the contacts CRM with an explicit consent timestamp, feeding the\n * email-campaign audiences.\n */\nexport const newsletterHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const body =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const hostId = String(body.hostId ?? '')\n const email = String(body.email ?? '')\n .trim()\n .toLowerCase()\n const listId = String(body.listId ?? '').trim()\n /*\n * The key of the consent-group sentence the signup rendered, when it\n * rendered one (AGL-3320). The opt-in reaches the site's whole consent\n * group only when this is the group's current key; a signup that shows no\n * sentence sends none and subscribes the person to this site alone.\n */\n const disclosedConsentGroup =\n typeof body.__consentGroup === 'string'\n ? body.__consentGroup.trim().slice(0, 64)\n : ''\n if (!hostId || !EMAIL_PATTERN.test(email)) {\n return res.status(400).json({ error: 'Enter a valid email' })\n }\n // Keeps counting under the no-address bucket rather than being skipped: an\n // unauthenticated signup endpoint that stops counting is an open write into\n // the contacts CRM.\n const ip =\n readClientIp(req.headers, { remoteAddress: req.socket?.remoteAddress }) ??\n NO_CLIENT_ADDRESS_BUCKET\n const now = Date.now()\n const attempts = (attemptsByIp.get(ip) ?? []).filter(\n (at) => now - at < 60_000,\n )\n attempts.push(now)\n attemptsByIp.set(ip, attempts)\n if (attempts.length > 10) {\n return res.status(429).json({ error: 'Too many attempts' })\n }\n try {\n // A newsletter signup is an identify moment: the visitor was anonymous\n // while they browsed and this request is the first thing that names them.\n // Where they arrived from is asked once, here, of the plugin that credits\n // outcomes, and handed to the record the capture files.\n const arrival = await resolveConversionTouch({\n hostId,\n wire: body.campaignTouch,\n email,\n atMs: now,\n })\n /*\n * Reported to whichever plugin keeps people, not written by this one\n * (AGL-3080). A newsletter signup is commerce meeting somebody; what a\n * person record is, and whether this workspace keeps any, belongs to the\n * plugin that models them.\n *\n * Still awaited, because the confirmation below is deliberately after it.\n */\n await recordCapturedContact({\n /*\n * Not resolved here. The record system keys a person on the SITE they\n * were met on, so this costs the capture nothing — and resolving it\n * would put a Firestore read in front of the writer, which is the one\n * thing `recordCapturedContact` asks a door not to do.\n */\n orgId: '',\n hostId,\n identity: { email },\n interaction: {\n source: 'newsletter',\n refId: `newsletter-${now}`,\n summary: 'Subscribed to the newsletter',\n },\n // A TOUCH (AGL-3232): the opt-in lands on the lead the site is\n // working for this address when it holds one, and on the contact\n // otherwise — the consent stays with the one record the person is.\n surface: 'touch',\n // The stage the name is for (AGL-2612): somebody who asked to hear\n // from the site and nothing more yet. A floor, so a customer who\n // subscribes stays a customer.\n lifecycleFloor: 'subscriber',\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n // Where the visitor ARRIVED from — a fact about this visit and not\n // about the person, which is what `detail` carries.\n ...(arrival ? { detail: { [CONVERSION_TOUCH_DETAIL]: arrival } } : {}),\n })\n /*\n * The confirmation, when this site asks for one.\n *\n * AFTER the contact upsert and never instead of it. The person typed\n * their address and ticked a box, and that consent record is a fact\n * whether or not they go on to click a link — withholding the capture\n * until they do would lose the record of what they actually did. What the\n * confirmation gates is the SEND, on the person's own topic entry, which\n * is where `filterTopicSendable` reads it.\n */\n const { topics, siteDefault, host } = await loadHostGate(hostId)\n const confirming =\n topicRequiresDoubleOptIn(\n topics.find((topic) => topic.id === NEWSLETTER_TOPIC_ID),\n siteDefault,\n ) && (await requestConfirmation({ hostId, email, topics, host }))\n if (listId) {\n /*\n * The same basis the contact upsert records, on the membership too.\n *\n * A list membership had no consent field at all, so `audience: 'list'`\n * gave the send-time join nothing to read even for the one audience\n * whose members literally asked for a newsletter\n * (`docs/specs/email-overhaul.md` §1d). This is a DECLARED opt-in and\n * not an inference from an act: the request this handler serves is\n * \"subscribe me\", which is the checkbox.\n */\n await enrollInList({\n hostId,\n listId,\n email,\n marketingConsent: true,\n ...(disclosedConsentGroup ? { disclosedConsentGroup } : {}),\n })\n }\n /*\n * The caller is told which of the two things happened, so the signup form\n * can say \"check your email\" rather than \"you're subscribed\".\n *\n * A form that reported success identically either way would be the site\n * telling somebody they are on the list while the send path refuses them,\n * and the only way they could find out is by noticing that nothing\n * arrives.\n */\n return res.status(200).json({ ok: true, confirmationRequired: confirming })\n } catch (error) {\n console.error(error)\n return res.status(500).json({ error: 'Signup failed' })\n }\n}\n"],"names":["hostPublicOrigin","mergeSubscriptionTopics","normalizeSubscriptionTopic","topicRequiresDoubleOptIn","pluginRecordIndex","NEWSLETTER_TOPIC_ID","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","CONVERSION_TOUCH_DETAIL","resolveConversionTouch","renderHostEmailWithTokens","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","SUBSCRIPTION_TOPIC_KIND","TOPIC_CATALOG_SCAN","enrollInList","options","listId","contactsRef","hostId","listRef","parent","collection","doc","listSnapshot","get","exists","group","email","name","source","marketingConsent","disclosedConsentGroup","error","console","requestConfirmation","topic","topics","find","candidate","id","result","siteBase","cname","String","host","subdomain","url","topicId","warn","stream","designed","app","firestore","confirmUrl","origin","catch","to","subject","text","html","sendingIdentity","audience","context","sent","loadTopics","keeper","orgId","records","index","list","limit","map","record","facts","filter","loadHostGate","siteDefault","snapshot","Promise","all","attemptsByIp","Map","NO_CLIENT_ADDRESS_BUCKET","readClientIp","newsletterHandler","req","res","body","method","status","json","JSON","parse","trim","toLowerCase","__consentGroup","slice","test","ip","headers","remoteAddress","socket","now","Date","attempts","at","push","set","length","arrival","wire","campaignTouch","atMs","identity","interaction","refId","summary","surface","lifecycleFloor","detail","confirming","ok","confirmationRequired"],"mappings":"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"}
|
|
@@ -95,6 +95,11 @@ export type PaidMediaDelivery = {
|
|
|
95
95
|
location: string;
|
|
96
96
|
expiresAtMs: number;
|
|
97
97
|
objectPath: string;
|
|
98
|
+
/** Whose library the object is in, for the bandwidth count. */
|
|
99
|
+
collection: 'hosts' | 'orgs';
|
|
100
|
+
scopeId: string;
|
|
101
|
+
/** The object's size, 0 when Storage did not say. */
|
|
102
|
+
sizeBytes: number;
|
|
98
103
|
} | {
|
|
99
104
|
ok: true;
|
|
100
105
|
via: 'external';
|
|
@@ -107,6 +112,11 @@ export type PaidMediaDelivery = {
|
|
|
107
112
|
/** The scope segment the delivery was minted for. */
|
|
108
113
|
scope: string;
|
|
109
114
|
mediaId: string;
|
|
115
|
+
/** Whose library the asset is in, for the bandwidth count. */
|
|
116
|
+
collection: 'hosts' | 'orgs';
|
|
117
|
+
scopeId: string;
|
|
118
|
+
/** The size of the copy the provider serves. */
|
|
119
|
+
sizeBytes: number;
|
|
110
120
|
} | {
|
|
111
121
|
ok: false;
|
|
112
122
|
refusal: PaidMediaRefusal;
|
|
@@ -132,6 +142,12 @@ export interface PaidMediaDeliveryIo {
|
|
|
132
142
|
signStorageRead(objectPath: string, expiresAtMs: number, options?: {
|
|
133
143
|
attachment?: boolean;
|
|
134
144
|
}): Promise<string>;
|
|
145
|
+
/**
|
|
146
|
+
* The object's size in bytes, for the bandwidth a signed read can send;
|
|
147
|
+
* 0 when it cannot be read. Optional, so a double that never signs a
|
|
148
|
+
* Storage read need not answer it.
|
|
149
|
+
*/
|
|
150
|
+
storageObjectSize?(objectPath: string): Promise<number>;
|
|
135
151
|
}
|
|
136
152
|
/** Is this signed read a download, by the caller's ask or by what the object is? */
|
|
137
153
|
export declare function storageReadIsAttachment(objectPath: string, cdnParams: ReadonlyArray<readonly [string, string]> | undefined): boolean;
|
|
@@ -190,6 +206,11 @@ export declare function createPaidMediaDeliveryIo(options: {
|
|
|
190
206
|
expires: number;
|
|
191
207
|
responseDisposition?: string;
|
|
192
208
|
}): Promise<[string] | string[]>;
|
|
209
|
+
getMetadata?(): Promise<[{
|
|
210
|
+
size?: unknown;
|
|
211
|
+
}, ...unknown[]] | Array<{
|
|
212
|
+
size?: unknown;
|
|
213
|
+
}>>;
|
|
193
214
|
};
|
|
194
215
|
};
|
|
195
216
|
}): PaidMediaDeliveryIo;
|