@aglyn/plugins-commerce 1.0.0-beta.219 → 1.0.0-beta.220
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 +5 -0
- package/package.json +14 -14
- package/src/lib/components/console/catalog-organization-card.component.js +626 -446
- package/src/lib/components/console/catalog-organization-card.component.js.map +1 -1
- package/src/lib/components/console/payments-settings-card.component.js +1 -1
- package/src/lib/components/console/payments-settings-card.component.js.map +1 -1
- package/src/lib/components/console/product-editor-dialog.component.js +1127 -846
- package/src/lib/components/console/product-editor-dialog.component.js.map +1 -1
- package/src/lib/components/console/site-member-purchases.component.d.ts +24 -0
- package/src/lib/components/console/site-member-purchases.component.js +339 -0
- package/src/lib/components/console/site-member-purchases.component.js.map +1 -0
- package/src/lib/components/member-signup.js +2 -2
- package/src/lib/components/member-signup.js.map +1 -1
- package/src/lib/components/newsletter-signup.js +2 -2
- package/src/lib/components/newsletter-signup.js.map +1 -1
- package/src/lib/constants/operator-alerts.js +5 -3
- package/src/lib/constants/operator-alerts.js.map +1 -1
- package/src/lib/model/commerce-io.js +1 -1
- package/src/lib/model/commerce-io.js.map +1 -1
- package/src/lib/model/commerce-record-lists.d.ts +31 -0
- package/src/lib/model/commerce-record-lists.js +45 -0
- package/src/lib/model/commerce-record-lists.js.map +1 -0
- package/src/lib/model/commerce-record-routes.d.ts +18 -0
- package/src/lib/model/commerce-record-routes.js +1 -1
- package/src/lib/model/commerce-record-routes.js.map +1 -1
- package/src/lib/model/commerce-search-sources.js +4 -2
- package/src/lib/model/commerce-search-sources.js.map +1 -1
- package/src/lib/model/commerce-topics.d.ts +20 -0
- package/src/lib/model/commerce-topics.js +33 -0
- package/src/lib/model/commerce-topics.js.map +1 -0
- package/src/lib/model/index.d.ts +1 -0
- package/src/lib/model/index.js +1 -0
- package/src/lib/model/index.js.map +1 -1
- package/src/lib/model/product-record.d.ts +32 -0
- package/src/lib/model/product-record.js +82 -0
- package/src/lib/model/product-record.js.map +1 -0
- package/src/lib/model/site-member-purchases.d.ts +48 -0
- package/src/lib/model/site-member-purchases.js +50 -0
- package/src/lib/model/site-member-purchases.js.map +1 -0
- package/src/lib/plugin.js +12 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/billing-webhook.js +342 -170
- package/src/lib/server/billing-webhook.js.map +1 -1
- package/src/lib/server/cart-checkout.js +1 -1
- package/src/lib/server/cart-checkout.js.map +1 -1
- package/src/lib/server/gift-cards.js +7 -3
- package/src/lib/server/gift-cards.js.map +1 -1
- package/src/lib/server/low-stock.js +3 -1
- package/src/lib/server/low-stock.js.map +1 -1
- package/src/lib/server/member-post.js +2 -2
- package/src/lib/server/member-post.js.map +1 -1
- package/src/lib/server/membership-admin-password.js +4 -1
- package/src/lib/server/membership-admin-password.js.map +1 -1
- package/src/lib/server/membership-register.js +13 -2
- package/src/lib/server/membership-register.js.map +1 -1
- package/src/lib/server/newsletter.js +32 -19
- package/src/lib/server/newsletter.js.map +1 -1
- package/src/lib/server/pos-order.js +1 -1
- package/src/lib/server/pos-order.js.map +1 -1
- package/src/lib/server/process-abandoned.js +4 -2
- package/src/lib/server/process-abandoned.js.map +1 -1
- package/src/lib/server/process-restock.js +7 -5
- package/src/lib/server/process-restock.js.map +1 -1
- package/src/lib/server/product-record-index.js +5 -37
- package/src/lib/server/product-record-index.js.map +1 -1
- package/src/lib/server/reconcile-stock.js +1 -1
- package/src/lib/server/reconcile-stock.js.map +1 -1
- package/src/lib/server/reserve-stock.js +6 -1
- package/src/lib/server/reserve-stock.js.map +1 -1
- package/src/lib/server/revenue-source.js +3 -3
- package/src/lib/server/revenue-source.js.map +1 -1
- package/src/lib/server/supplier-outbox.js +16 -1
- package/src/lib/server/supplier-outbox.js.map +1 -1
- package/src/lib/server/supplier-update.js +19 -9
- package/src/lib/server/supplier-update.js.map +1 -1
- package/src/lib/starter-templates.d.ts +19 -0
- package/src/lib/starter-templates.js +157 -0
- package/src/lib/starter-templates.js.map +1 -0
- package/src/lib/tenant-emails.js +54 -14
- package/src/lib/tenant-emails.js.map +1 -1
|
@@ -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 mergeEmailTopics,\n normalizeEmailTopic,\n topicRequiresDoubleOptIn,\n EMAIL_TOPICS_COLLECTION,\n type EmailTopic,\n} from '@aglyn/aglyn/app-utils/email-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.\n *\n * The built-in id, not the campaign default: somebody typing their address\n * into a footer box is asking for the newsletter, and attributing it to\n * \"Promotions and offers\" would confirm them for a stream they did not name.\n */\nconst NEWSLETTER_TOPIC_ID = 'newsletter'\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: EmailTopic[]\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 ?? 'our 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 receive ${stream} 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 * Fails soft to the built-ins, like the preference page's read: a site whose\n * org cannot be resolved still gets the four defaults, so the decision is\n * made against a catalog rather than against nothing.\n */\nasync function loadTopics(hostId: string): Promise<EmailTopic[]> {\n try {\n const orgId = await resolveOrgIdForHost(hostId)\n if (!orgId) return mergeEmailTopics(null)\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(EMAIL_TOPICS_COLLECTION)\n .get()\n return mergeEmailTopics(\n (snapshot?.docs ?? [])\n .map((doc: any) => normalizeEmailTopic(doc.id, doc.data()))\n .filter((topic: EmailTopic | null): topic is EmailTopic => !!topic),\n )\n } catch (error) {\n console.error('[newsletter] topic catalog read failed', error)\n return mergeEmailTopics(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: EmailTopic[]\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","mergeEmailTopics","normalizeEmailTopic","topicRequiresDoubleOptIn","EMAIL_TOPICS_COLLECTION","enrollListMember","firebaseAdmin","hostSendingIdentity","meterHostEmail","consentGroupForSite","orgDataCollectionForHost","recordPendingTopicConfirmation","resolveCampaignTouch","resolveOrgIdForHost","siteRequiresDoubleOptIn","recordCapturedContact","renderHostEmailWithTokens","buildConfirmUrl","sendEmail","isDocumentId","EMAIL_PATTERN","NEWSLETTER_TOPIC_ID","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","orgId","snapshot","docs","map","data","filter","loadHostGate","siteDefault","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 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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"}
|
|
@@ -524,7 +524,7 @@ import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
|
524
524
|
// cashier's discount, plus the origin tax. A till knows both — there is no
|
|
525
525
|
// shopper-chosen shipping and no Stripe Tax to wait on — so unlike the
|
|
526
526
|
// online doors this one recovers the cost exactly.
|
|
527
|
-
const feeCents = payment === 'link' ? takeFeeCents + Aglyn.
|
|
527
|
+
const feeCents = payment === 'link' ? takeFeeCents + Aglyn.saleProcessingCostCents(chargedItemsCents + taxCents) : takeFeeCents;
|
|
528
528
|
const totals = CommerceModel.computeOrderTotals(lineItems, {
|
|
529
529
|
discountCents,
|
|
530
530
|
taxCents,
|