@aglyn/plugins-crm 1.0.0-beta.164 → 1.0.0-beta.166
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contact-associations-card.js +21 -5
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-field-drawer.js +19 -2
- package/src/lib/components/contact-field-drawer.js.map +1 -1
- package/src/lib/components/crm-console-page.js +12 -7
- package/src/lib/components/crm-console-page.js.map +1 -1
- package/src/lib/components/crm-glance-card.js +4 -2
- package/src/lib/components/crm-glance-card.js.map +1 -1
- package/src/lib/components/fields-section.d.ts +2 -1
- package/src/lib/components/fields-section.js +6 -3
- package/src/lib/components/fields-section.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.d.ts +59 -0
- package/src/lib/components/lead-campaigns-card.js +166 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -0
- package/src/lib/components/lead-detail-page.js +54 -4
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-history-card.js +5 -0
- package/src/lib/components/lead-history-card.js.map +1 -1
- package/src/lib/components/lead-properties-card.d.ts +12 -0
- package/src/lib/components/lead-properties-card.js +64 -6
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +7 -1
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.d.ts +7 -2
- package/src/lib/components/leads-bulk-bar.js +42 -6
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +43 -17
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-lead-drawer.d.ts +14 -1
- package/src/lib/components/new-lead-drawer.js +41 -3
- package/src/lib/components/new-lead-drawer.js.map +1 -1
- package/src/lib/components/reports/lead-counts.d.ts +16 -9
- package/src/lib/components/reports/lead-counts.js +29 -18
- package/src/lib/components/reports/lead-counts.js.map +1 -1
- package/src/lib/components/reports/lead-funnel-card.js +44 -40
- package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
- package/src/lib/hooks/use-campaign-filing-log.d.ts +29 -0
- package/src/lib/hooks/use-campaign-filing-log.js +91 -0
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -0
- package/src/lib/hooks/use-org-leads.d.ts +29 -32
- package/src/lib/hooks/use-org-leads.js +58 -68
- package/src/lib/hooks/use-org-leads.js.map +1 -1
- package/src/lib/model/campaign-filing-activity.d.ts +82 -0
- package/src/lib/model/campaign-filing-activity.js +88 -0
- package/src/lib/model/campaign-filing-activity.js.map +1 -0
- package/src/lib/model/crm-routes.d.ts +9 -6
- package/src/lib/model/crm-routes.js +9 -6
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.d.ts +79 -0
- package/src/lib/server/campaign-filing-activity.js +93 -0
- package/src/lib/server/campaign-filing-activity.js.map +1 -0
- package/src/lib/server/capture-contact.js +11 -5
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +6 -2
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/email-send.js +3 -3
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/erase-person.js +25 -7
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/lead-campaign-carry.js +28 -2
- package/src/lib/server/lead-campaign-carry.js.map +1 -1
- package/src/lib/server/lead-create.d.ts +15 -4
- package/src/lib/server/lead-create.js +75 -15
- package/src/lib/server/lead-create.js.map +1 -1
- package/src/lib/server/leads-import.js +2 -2
- package/src/lib/server/leads-import.js.map +1 -1
- package/src/lib/server/record-email-state.js +10 -0
- package/src/lib/server/record-email-state.js.map +1 -1
- package/src/lib/server/record-facts.js +3 -3
- package/src/lib/server/record-facts.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/email-send.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildCrmEmailActivity,\n contactCaptureHostIds,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_CONTEXT,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivityLink,\n crmActivityLogHasRoom,\n crmEmailDeliveryTags,\n emailStateRefusal,\n type CrmMergeContext,\n crmMergeFieldsIn,\n crmScopeTokens,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n readContactFacet,\n readEmailState,\n readMarketingBasis,\n renderCrmMergeFields,\n resolveOrgEntitlements,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n isEmailConfigured,\n sendEmail,\n sendFailureReason,\n type SendEmailResult,\n} from '@aglyn/shared-util-email'\nimport {\n consentGroupForSite,\n consumeRateLimit,\n countCrmActivitiesForRecord,\n filterSendableForHost,\n firebaseAdmin,\n getHostDocAdmin,\n getOrgForHost,\n hostSendingIdentity,\n logOrgActivity,\n memberHasOrgPermission,\n newCrmActivityRef,\n orgDataCollectionForHost,\n readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(ids.leadId)\n .get()\n if (!lead.exists) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n // The record's own verdict (AGL-3245): what the lists hold, in the words\n // the page shows — a bounce, a gateway block, a do-not-contact mark the\n // suppression lists do not carry.\n const stateRefusal = emailStateRefusal(readEmailState(recipient.record))\n if (stateRefusal) {\n return answer(res, refuse(409, stateRefusal, { reason: 'email-state' }))\n }\n\n if (!isEmailConfigured()) {\n return answer(\n res,\n refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n }),\n )\n }\n const identity = await hostSendingIdentity(hostId)\n if (identity.refusal) {\n return answer(\n res,\n refuse(409, identity.refusal.message, { reason: 'sending-identity' }),\n )\n }\n\n const now = new Date()\n /*\n * THE NEW-WORKSPACE RAMP, resolved for the ceiling below.\n *\n * A workspace past its first week graduates off the org record alone and\n * pays no read for this. One that is still on the ramp needs its\n * delivery history to know which step it has earned, and when that\n * history is unreadable the step is a floor rather than an answer.\n *\n * A floor is enough for a campaign, which defers what it cannot send\n * today and delivers it tomorrow. It is not enough here: this route\n * admits or refuses ONE message and has no third answer, so a ramp that\n * cannot say what a workspace has earned refuses and says so. The\n * refusal is transient by construction — it lasts as long as the window\n * read is failing — and it reaches only workspaces inside their first\n * week, since every other one graduated before the window was consulted.\n */\n const { ramp, degraded } = await readOrgEmailRamp({\n orgId,\n createdAt: (org as Record<string, unknown>)['createdAt'],\n now: now.getTime(),\n })\n if (degraded) {\n return answer(\n res,\n refuse(503, CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE, {\n reason: 'ramp-unavailable',\n }),\n )\n }\n\n // THE DAILY CAP (AGL-2611), taken as a slot rather than read as a\n // figure (AGL-2645): the transaction reads today's counter, judges it\n // with `checkCrmEmailQuota` against the ramp's share of the plan's day\n // and raises it in one step, so the count the cap enforces is the count\n // this send was admitted on.\n const claim = await reserveCrmEmailSend(firestore, orgId, org, now, ramp)\n if (!claim.ok) {\n const { quota } = claim\n // WHICH ceiling was reached, in the org's own terms. The reservation\n // answers with the number it judged, which is the plan's day unless\n // the ramp held this workspace below it — and a workspace shown a\n // figure under the one its plan page advertises is owed the reason.\n const rampBound =\n quota.included < resolveOrgEntitlements(org).crmEmailsPerDay\n return answer(\n res,\n refuse(\n 409,\n quota.included <= 0\n ? CRM_EMAIL_NOT_INCLUDED_MESSAGE\n : rampBound\n ? crmEmailRampReachedMessage(quota.included, quota.used)\n : crmEmailCapReachedMessage(quota.included),\n {\n reason: rampBound ? 'ramp' : 'quota',\n included: quota.included,\n used: quota.used,\n resetsAtMs: quota.resetsAt.getTime(),\n },\n ),\n )\n }\n\n // The row's id, minted before the send so the delivery webhook can find\n // it from the tags alone; written only once the message has left.\n const activityRef = newCrmActivityRef(firestore, orgId)\n let result: SendEmailResult\n try {\n result = await sendEmail({\n to: recipient.email,\n subject,\n text,\n sendingIdentity: identity,\n audience: 'tenant',\n context: CRM_EMAIL_CONTEXT,\n replyTo: sender.email,\n ...(sender.name ? { fromName: sender.name } : {}),\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: activityRef.id }),\n })\n } catch (error) {\n // The provider never answered, so no message left: the slot goes\n // back before the failure is reported.\n await releaseCrmEmailSend(firestore, claim.reservation)\n throw error\n }\n if (!result.sent) {\n await releaseCrmEmailSend(firestore, claim.reservation)\n return answer(res, sendRefusal(result))\n }\n\n const sentAtMs = Date.now()\n let logged = true\n /*\n * Who may list the row. Under a site, what a record created there is\n * stamped with. At the organization level the RECORD's own tokens, so\n * an email to a person another site captured shows on their timeline\n * under that site's hub — a row stamped with the sending site alone\n * would be invisible where the person is read.\n */\n const recordTokens = recipient.record['visibleTo']\n const visibleTo =\n routeScope.level === 'org' && Array.isArray(recordTokens) && recordTokens.length\n ? recordTokens.map(String)\n : crmScopeTokens(org as Record<string, unknown>, group)\n try {\n await writeCrmEmailActivity(\n activityRef,\n buildCrmEmailActivity({\n subject,\n body: text,\n to: recipient.email,\n atMs: sentAtMs,\n byUid: sender.uid,\n ...(sender.name ? { byName: sender.name } : {}),\n link: recipient.link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n // The message has left. A row that could not be written is a gap on\n // the timeline, not a failed send, and it is said so in the answer.\n console.error('[crm] email activity write failed', orgId, activityRef.id, error)\n logged = false\n }\n // The cap's count was taken with the reservation; the org's cost meter\n // for COGS is stamped after delivery, unable to fail the send.\n await recordEmailSends({\n scope: { kind: 'org', orgId },\n count: 1,\n sendClass: 'transactional',\n firestore,\n })\n // The org-level act, in the org's feed: the site variant's is the\n // timeline row itself, under the site the record page is on.\n if (routeScope.level === 'org') {\n await logOrgActivity(\n orgId,\n { uid: sender.uid, email: sender.email },\n 'Sent email',\n recipient.link.contactId\n ? { type: 'contact', id: recipient.link.contactId, name: recipient.email }\n : { type: 'lead', id: recipient.link.leadId, name: recipient.email },\n )\n }\n res.status(200).json({\n ok: true,\n activityId: activityRef.id,\n to: recipient.email,\n from: identity.from,\n logged,\n })\n } catch (error) {\n console.error('[crm] email-send failed', routeScope, error)\n res.status(500).json({ error: 'The email could not be sent.' })\n }\n}\n\nexport default crmEmailSendHandler\n"],"names":["buildCrmEmailActivity","contactCaptureHostIds","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","CRM_EMAIL_BODY_MAX","CRM_EMAIL_CONTEXT","CRM_EMAIL_SUBJECT_MAX","crmActivityLogHasRoom","crmEmailDeliveryTags","emailStateRefusal","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readEmailState","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","stateRefusal","identity","message","ramp","degraded","createdAt","getTime","claim","quota","rampBound","crmEmailsPerDay","resetsAtMs","resetsAt","activityRef","to","sendingIdentity","audience","context","replyTo","fromName","tags","activityId","reservation","sent","sentAtMs","logged","recordTokens","visibleTo","Array","isArray","map","atMs","byUid","byName","kind","count","sendClass","type","from"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/email-send.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgBilling,\n type AglynOrgMember,\n buildCrmEmailActivity,\n contactCaptureHostIds,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n CRM_EMAIL_BODY_MAX,\n CRM_EMAIL_CONTEXT,\n CRM_EMAIL_SUBJECT_MAX,\n type CrmActivityLink,\n crmActivityLogHasRoom,\n crmEmailDeliveryTags,\n emailStateRefusal,\n type CrmMergeContext,\n crmMergeFieldsIn,\n crmScopeTokens,\n isOrgWideMember,\n normalizeContactEmail,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n readContactFacet,\n readEmailState,\n readMarketingBasis,\n renderCrmMergeFields,\n resolveOrgEntitlements,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n isEmailConfigured,\n sendEmail,\n sendFailureReason,\n type SendEmailResult,\n} from '@aglyn/shared-util-email'\nimport {\n consentGroupForSite,\n consumeRateLimit,\n countCrmActivitiesForRecord,\n filterSendableForHost,\n firebaseAdmin,\n getHostDocAdmin,\n getOrgForHost,\n hostSendingIdentity,\n logOrgActivity,\n memberHasOrgPermission,\n newCrmActivityRef,\n orgDataCollectionForHost,\n readLeadForHost,\n readOrgEmailRamp,\n recordEmailSends,\n releaseCrmEmailSend,\n reserveCrmEmailSend,\n resolveOrgMembership,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n orgHostIds,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * `POST /api/crm/email-send` — one email to one person, from their record\n * (AGL-2615).\n *\n * Body: `{ hostId, contactId? | leadId? | dealId?, subject, body }`. Answers\n * `{ ok: true, activityId, to, from, logged }` once the provider accepted\n * the message, and a refusal with a `reason` the dialog can act on\n * otherwise. The subject and the body may carry merge fields —\n * `{{contact.firstName}}`, `{{deal.amount}}` (AGL-2658) — which this route\n * fills from the record before the send, so the timeline row is the letter\n * as it left; see `crm-email-templates.ts` for the grammar.\n *\n * ## What it is, and what it is not\n *\n * A message a teammate wrote to one person they have a relationship with —\n * a reply to a lead, a follow-up on a deal. It is not marketing: no\n * campaign audience, no unsubscribe pair, no frequency window, no consent\n * split. What it does owe is everything a bounce or a complaint owes the\n * shared sending domain, which is why BOTH suppression lists are consulted\n * and why a person's own stated refusal — a `declined` basis — is a hard\n * stop even though the message is not the kind of mail that basis governs.\n * The recipient asked this site not to write; the kind of writing is not\n * the point.\n *\n * ## Why the recipient is resolved here and not taken from the body\n *\n * The dialog shows the address the record carries, and this route reads it\n * again off the record the caller named, through the same visibility check\n * every other CRM route makes. A `to` in the body would make this a route\n * that sends the site's mail to any address a member types, which is a\n * different product with a different abuse surface.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, … }` from the org-level hub. The caller is authorized\n * by the org — an org-wide member holding `data.manage` — and the record is\n * read with no site's visibility to check, because an org-wide member reads\n * every row. What the org level cannot do away with is the SITE the message\n * leaves from: the sending identity, the suppression list and the person's\n * stated refusal are each a site's. So the org variant still names one —\n * the record's own capturing site or the site the reader picked, in the\n * body; failing that the record's own site read off the document; failing\n * that the org's only site — and refuses, with a `site` reason, when the\n * org has several and none was named. The row it writes is visible to\n * whoever may see the RECORD, so an email to a person another site\n * captured shows on their timeline under that site's hub; the act is\n * logged in the org's feed.\n *\n * ## The order of the gates\n *\n * Cheap and certain first. A malformed body and a missing token cost\n * nothing; the per-user pace is one counter; the record is one read and\n * decides everything after it; the ceiling one aggregate; the two\n * suppression lists two keyed reads; the sending identity next, because it\n * is the most expensive and the one a workspace fixes once. The daily cap\n * comes LAST and is the one gate that writes before the provider is called\n * (AGL-2645): the send is reserved on today's counter inside a transaction,\n * so two reps at the last slot cannot both pass one reading of it, and a\n * message the provider refuses gives the slot back. After the provider has\n * accepted, two more things are written, neither able to fail the send:\n * the activity row and the org's cost meter.\n *\n * ## The new-workspace ramp is part of that cap, not a gate beside it\n *\n * A message from a workspace created this morning lands on the sending\n * domain every other tenant's receipts leave on, so the plan's day is held\n * to the share of it the workspace has earned — the same ramp the campaign\n * path is paced by, `readOrgEmailRamp`, taken to the allowance this surface\n * meters instead of the campaign ceiling. It is handed to the reservation\n * rather than checked before it, so the ceiling and the count it is judged\n * against come out of the one transactional read; a ramp consulted\n * separately would be a second ceiling and two reps could pass it at once.\n * A workspace past its first week is not ramped and pays nothing to find\n * that out.\n */\n\n/** How many one-to-one emails one person may send in one minute. */\nexport const CRM_EMAIL_SENDS_PER_MINUTE = 20\nconst CRM_EMAIL_RATE_WINDOW_MS = 60_000\n\n/** What the route says when the plan carries no one-to-one email at all. */\nexport const CRM_EMAIL_NOT_INCLUDED_MESSAGE =\n 'Your plan does not include one-to-one email. Upgrade in Billing to email ' +\n 'people from their records.'\n\n/** What the route says at the daily cap; `included` is the plan's figure. */\nexport function crmEmailCapReachedMessage(included: number): string {\n return (\n `Today's one-to-one email limit (${included.toLocaleString('en-US')}) is ` +\n 'reached. It resets at midnight UTC — see Billing for the count.'\n )\n}\n\n/**\n * What the route says when the NEW-WORKSPACE RAMP is the ceiling that was\n * reached, rather than the plan's own.\n *\n * The campaign path's wording, pointed at this surface: the number, what was\n * spent against it, that nothing was sent or counted, and when it lifts. A\n * workspace told only \"limit reached\" against a figure far below the one its\n * plan page advertises would read it as a billing fault.\n */\nexport function crmEmailRampReachedMessage(\n ceiling: number,\n used: number,\n): string {\n return (\n `This workspace may send ${ceiling.toLocaleString('en-US')} one-to-one ` +\n `email${ceiling === 1 ? '' : 's'} a day while it establishes a sending ` +\n `history, and has sent ${used.toLocaleString('en-US')} today. Nothing ` +\n 'has been sent and nothing has been counted — the allowance grows as ' +\n \"the workspace sends, and is the plan's in full after its first week. \" +\n 'It resets at midnight UTC.'\n )\n}\n\n/** What the route says when the ramp's own history could not be read. */\nexport const CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE =\n \"This workspace's sending history could not be read just now, so how much \" +\n 'of its first-week email allowance it has earned is not known. Nothing has ' +\n 'been sent and nothing has been counted — try again in a moment.'\n\nexport const CRM_EMAIL_SUPPRESSED_MESSAGE =\n 'This address has bounced or reported a message as spam, so it cannot be ' +\n 'emailed.'\n\nexport const CRM_EMAIL_DECLINED_MESSAGE =\n 'This person asked not to be emailed by this site.'\n\nexport const CRM_EMAIL_RATE_MESSAGE =\n 'You have sent too many emails in the last minute. Wait a moment and try again.'\n\n/** What the org variant says when no site was named and the org has several. */\nexport const CRM_EMAIL_PICK_SITE_MESSAGE =\n 'Pick the site this email leaves from — its sending address is what the ' +\n 'message is sent as.'\n\ntype Refusal = { ok: false; status: number; body: Record<string, unknown> }\n\nconst refuse = (\n status: number,\n error: string,\n extra: Record<string, unknown> = {},\n): Refusal => ({ ok: false, status, body: { error, ...extra } })\n\n/** One typed field, trimmed and bounded. */\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\ninterface Sender {\n ok: true\n uid: string\n /** The address replies go to — the token's, never the body's. */\n email: string\n /** The name in front of the sending address, when the token carries one. */\n name: string\n orgId: string\n org: Partial<AglynOrgBilling>\n}\n\n/**\n * Who is sending, and whether they may: a verified ID token, then\n * `data.manage` on the site's org through the same three-layer read the\n * create route makes, admitted only for a site the member reaches. Staff\n * pass the permission check as they do on every CRM route, and send as\n * themselves.\n */\nasync function authorizeSender(\n req: PluginApiRequest,\n hostId: string,\n): Promise<Sender | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n let decoded: { uid: string; email?: string; name?: string; staff?: unknown }\n try {\n decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return refuse(401, 'Unauthenticated')\n console.error('[crm] email-send could not verify the sender', error)\n return refuse(500, 'The sign-in could not be checked. Try again.')\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n if (!resolved) return refuse(404, 'Unknown site')\n const { orgId } = resolved\n const org = (resolved.org ?? {}) as Partial<AglynOrgBilling>\n if (decoded.staff !== true) {\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(orgId, member, 'data.manage'))\n if (!allowed) {\n return refuse(\n 403,\n 'Sending email from a record requires the data.manage permission on this site',\n )\n }\n }\n const email = normalizeContactEmail(decoded.email)\n if (!email) {\n // A reply-to is owed and the token is the only place it can honestly\n // come from; an account with no address cannot be replied to.\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: decoded.uid,\n email,\n name: typed(decoded.name, 120),\n orgId,\n org,\n }\n}\n\n/**\n * The org variant's sender (AGL-2634): authorized by the org for an\n * org-wide member holding `data.manage`, with the same reply-to rule.\n */\nasync function authorizeOrgSender(\n req: PluginApiRequest,\n orgId: string,\n): Promise<Sender | Refusal> {\n const caller = await authorizeOrgCaller(req, orgId, {\n needs: 'data.manage',\n refusal:\n 'Sending email at the organization level requires the data.manage ' +\n 'permission across the whole workspace',\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n const email = normalizeContactEmail(caller.email)\n if (!email) {\n return refuse(403, 'Your account has no email address to receive replies at.')\n }\n return {\n ok: true,\n uid: caller.uid,\n email,\n name: caller.name,\n orgId,\n org: caller.org as Partial<AglynOrgBilling>,\n }\n}\n\ninterface Recipient {\n ok: true\n email: string\n /** The document the consent basis is read off — the contact, or the lead. */\n record: Record<string, unknown>\n /** The contact document, when one was read — where the company link is. */\n contact: Record<string, unknown> | null\n link: CrmActivityLink\n /**\n * The site the record itself names — a contact's first capturing site, a\n * deal's own, a lead's — which is the site an org-level send leaves from\n * when the body named none.\n */\n siteHint: string\n /** The deal document, when one was named — what `{{deal.*}}` reads (AGL-2658). */\n deal: Record<string, unknown> | null\n /** The lead document, when the recipient is a lead — what `{{lead.*}}` reads. */\n lead: Record<string, unknown> | null\n}\n\n/**\n * The person the named record is about, as THIS site may see them.\n *\n * A deal names its contact; a lead carries its own address; a contact is\n * itself. Each is looked up by id and then checked against what the site\n * may read — the Admin SDK evaluates no rules, and a member of site A must\n * not be able to mail site B's contact by guessing an id. The links the\n * activity is filed under are whatever resolved: a deal's email is also the\n * contact's, so it lands on both timelines.\n *\n * At the organization level there is no site to check against and none is\n * checked: the caller was admitted as an org-wide member, who reads every\n * row. A lead still needs its site named — a lead lives under one.\n */\nasync function resolveRecipient(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n ids: { contactId: string; leadId: string; dealId: string },\n): Promise<Recipient | Refusal> {\n const link: CrmActivityLink = {}\n const { hostId } = scope\n const scoped = scope.level === 'site'\n const visible = (tokens: unknown) =>\n !scoped || visibleToHost(tokens as readonly string[], hostId)\n let contactId = ids.contactId\n let siteHint = ''\n let dealRecord: Record<string, unknown> | null = null\n\n if (ids.dealId) {\n const deal = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.deals)\n .doc(ids.dealId)\n .get()\n if (!deal.exists || !visible(deal.get('visibleTo'))) {\n return refuse(404, 'Unknown deal')\n }\n link.dealId = deal.id\n dealRecord = (deal.data() ?? {}) as Record<string, unknown>\n siteHint = typed(deal.get('hostId'), 128)\n contactId = contactId || String(deal.get('contactId') ?? '').trim()\n if (!contactId) {\n return refuse(400, 'This deal names no contact to email.')\n }\n }\n\n if (contactId) {\n const contacts = scoped\n ? await orgDataCollectionForHost(hostId, 'contacts')\n : firestore.collection('orgs').doc(orgId).collection('contacts')\n const contact = await contacts.doc(contactId).get()\n if (!contact.exists || !visible(contact.get('visibleTo'))) {\n return refuse(404, 'Unknown contact')\n }\n const email = normalizeContactEmail(contact.get('email'))\n if (!email) return refuse(400, 'This contact has no email address.')\n const record = (contact.data() ?? {}) as Record<string, unknown>\n link.contactId = contact.id\n if (ids.leadId) link.leadId = ids.leadId\n siteHint =\n siteHint || typed(record['hostId'], 128) || contactCaptureHostIds(record)[0] || ''\n return { ok: true, email, record, contact: record, link, siteHint, deal: dealRecord, lead: null }\n }\n\n if (ids.leadId) {\n if (!hostId) return refuse(400, 'Name the site the lead lives under.')\n const lead = await readLeadForHost(hostId, ids.leadId)\n if (!lead) return refuse(404, 'Unknown lead')\n const email = normalizeContactEmail(lead.get('email'))\n if (!email) return refuse(400, 'This lead has no email address.')\n const record = (lead.data() ?? {}) as Record<string, unknown>\n link.leadId = lead.id\n // A converted lead's email belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return { ok: true, email, record, contact: null, link, siteHint: hostId, deal: null, lead: record }\n }\n\n return refuse(400, 'Name a contact, lead or deal to email.')\n}\n\n/**\n * The site an org-level send leaves from (AGL-2634): the body's, else the\n * record's own, else the org's only site — and a refusal the dialog turns\n * into a Site picker when the org has several and none was named.\n */\nasync function resolveSendingSite(\n firestore: FirebaseFirestore.Firestore,\n scope: CrmRouteScope,\n orgId: string,\n siteHint: string,\n): Promise<{ ok: true; hostId: string } | Refusal> {\n if (scope.hostId) return { ok: true, hostId: scope.hostId }\n if (siteHint) return { ok: true, hostId: siteHint }\n const hosts = await orgHostIds(firestore, orgId)\n if (hosts.length === 1) return { ok: true, hostId: hosts[0] }\n return refuse(409, CRM_EMAIL_PICK_SITE_MESSAGE, { reason: 'site' })\n}\n\n/** The HTTP shape of a send the provider did not accept. */\nfunction sendRefusal(result: SendEmailResult): Refusal {\n const reason = sendFailureReason(result)\n const detail = String((result as { detail?: unknown }).detail ?? '')\n if (reason === 'unverified-domain') {\n return refuse(409, detail || 'This site has no verified sending identity.', {\n reason: 'sending-identity',\n })\n }\n if (reason === 'suppressed' || reason === 'unengaged') {\n return refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' })\n }\n if (reason === 'rate-limited') {\n const retryAtMs = Number((result as { retryAtMs?: unknown }).retryAtMs)\n return refuse(\n 503,\n 'The mail provider asked us to slow down. Try again in a minute.',\n {\n reason: 'provider-rate',\n retryAfterSeconds: Number.isFinite(retryAtMs)\n ? Math.max(1, Math.ceil((retryAtMs - Date.now()) / 1000))\n : 60,\n },\n )\n }\n if (reason === 'unconfigured') {\n return refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n })\n }\n return refuse(502, 'The email could not be sent.', { reason: 'send-failed' })\n}\n\n/** What the route says when a subject or a message is nothing once its fields are filled. */\nexport const CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE =\n 'The subject and the message are empty once their merge fields are filled ' +\n 'in. Check the fields against this record.'\n\n/**\n * What `{{…}}` reads from, for this send (AGL-2658): the documents already\n * in hand — the contact, the deal, the lead — the sender off their token,\n * and the site's name, read only when a field asks for it. The contact's\n * facet is the SENDING site's group's, because a name, a job title and a\n * company are one holder's knowledge of a person.\n */\nasync function mergeContextFor(options: {\n recipient: Recipient\n sender: Sender\n hostId: string\n groupId: string\n fields: readonly string[]\n}): Promise<CrmMergeContext> {\n const { recipient, sender, hostId, groupId, fields } = options\n const wantsSite = fields.some((field) => field.startsWith('site.'))\n const host = wantsSite ? await getHostDocAdmin(hostId).catch(() => null) : null\n return {\n contact: recipient.contact,\n contactGroupId: groupId,\n lead: recipient.lead,\n deal: recipient.deal,\n sender: { name: sender.name, email: sender.email },\n site: { name: typed(host?.['displayName'], 200) },\n }\n}\n\nfunction answer(res: PluginApiResponse, refusal: Refusal): void {\n const retry = refusal.body['retryAfterSeconds']\n if (typeof retry === 'number') res.setHeader('Retry-After', String(retry))\n res.status(refusal.status).json(refusal.body)\n}\n\nexport const crmEmailSendHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const body = (req.body ?? {}) as Record<string, unknown>\n const routeScope = readCrmRouteScope(body)\n if (!routeScope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const ids = {\n contactId: typed(body['contactId'], 128),\n leadId: typed(body['leadId'], 128),\n dealId: typed(body['dealId'], 128),\n }\n if (!ids.contactId && !ids.leadId && !ids.dealId) {\n res.status(400).json({ error: 'Name a contact, lead or deal to email.' })\n return\n }\n const draftSubject = typed(body['subject'], CRM_EMAIL_SUBJECT_MAX)\n if (!draftSubject) {\n res.status(400).json({ error: 'Enter a subject.' })\n return\n }\n // Paragraphs are what the dialog offers, and a plain-text part is what a\n // paragraph is; line endings are normalized so the stored body and the\n // sent body are one string.\n const draftText = String(body['body'] ?? '')\n .replace(/\\r\\n?/g, '\\n')\n .trim()\n .slice(0, CRM_EMAIL_BODY_MAX)\n if (!draftText) {\n res.status(400).json({ error: 'Write the message.' })\n return\n }\n\n try {\n const sender =\n routeScope.level === 'org'\n ? await authorizeOrgSender(req, routeScope.orgId)\n : await authorizeSender(req, routeScope.hostId)\n if (sender.ok === false) return answer(res, sender)\n const { orgId, org } = sender\n /*\n * THE PLAN, before the pace and the cap (AGL-2787). One-to-one email is\n * the suite's; a plan without the suite is refused as such, rather than\n * reaching the daily cap and being told about a zero it has no way to\n * raise short of the upgrade this names.\n */\n const suite = crmSuiteRefusal(org, 'Emailing one person from their record')\n if (suite) return answer(res, { ok: false, status: suite.status, body: suite.body })\n\n /*\n * THE PER-USER PACE, on its own key. The console dispatcher already\n * counts this write against the person's general console budget; this\n * is the narrower bucket the feature owes — twenty a minute is a rep\n * writing, and past it is a script — keyed by the uid the token proved\n * so one person cannot spend the bucket of everyone on their address.\n */\n const rate = await consumeRateLimit(`crm-email-send:uid:${sender.uid}`, {\n limit: CRM_EMAIL_SENDS_PER_MINUTE,\n windowMs: CRM_EMAIL_RATE_WINDOW_MS,\n })\n if (!rate.allowed) {\n return answer(\n res,\n refuse(429, CRM_EMAIL_RATE_MESSAGE, {\n reason: 'rate',\n retryAfterSeconds: Math.max(\n 1,\n Math.ceil((rate.resetMs - Date.now()) / 1000),\n ),\n }),\n )\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const recipient = await resolveRecipient(firestore, routeScope, orgId, ids)\n if (recipient.ok === false) return answer(res, recipient)\n // The site the message leaves from — the mounted one under a site, and\n // at the organization level whatever the body, the record or the org\n // can answer.\n const site = await resolveSendingSite(firestore, routeScope, orgId, recipient.siteHint)\n if (site.ok === false) return answer(res, site)\n const { hostId } = site\n const group = await consentGroupForSite(hostId, org as Record<string, unknown>)\n // The company the sending site files this person under, so the email\n // shows on the company's log as the automation step's rows do.\n if (recipient.contact) {\n const facet = readContactFacet(recipient.contact, group.groupId)\n if (facet.companyId) recipient.link.companyId = facet.companyId\n }\n\n /*\n * MERGE FIELDS (AGL-2658), filled here rather than in the dialog: the\n * dialog previews with the same resolver, but what leaves and what is\n * logged is rendered by the route, off the documents it read to find\n * the recipient, so a preview the rep did not look at cannot differ\n * from the letter. A subject or a message that is nothing once its\n * fields are filled is refused before any gate is spent on it.\n */\n const fields = crmMergeFieldsIn(`${draftSubject}\\n${draftText}`)\n const merge = fields.length\n ? await mergeContextFor({ recipient, sender, hostId, groupId: group.groupId, fields })\n : null\n const subject = merge\n ? renderCrmMergeFields(draftSubject, merge).trim().slice(0, CRM_EMAIL_SUBJECT_MAX)\n : draftSubject\n const text = merge\n ? renderCrmMergeFields(draftText, merge).trim().slice(0, CRM_EMAIL_BODY_MAX)\n : draftText\n if (!subject || !text) {\n return answer(res, refuse(400, CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE, { reason: 'merge' }))\n }\n\n // The per-record ceiling, before the send: an email that could not be\n // logged would be a message the timeline never shows, and the timeline\n // is the reason the send lives on the record.\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, recipient.link))) {\n return answer(res, refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE, { reason: 'ceiling' }))\n }\n\n // Both suppression lists, then the person's own refusal.\n const sendable = await filterSendableForHost(hostId, [recipient.email])\n if (!sendable.length) {\n return answer(res, refuse(409, CRM_EMAIL_SUPPRESSED_MESSAGE, { reason: 'suppressed' }))\n }\n if (readMarketingBasis(recipient.record, group).basis === 'declined') {\n return answer(res, refuse(409, CRM_EMAIL_DECLINED_MESSAGE, { reason: 'declined' }))\n }\n // The record's own verdict (AGL-3245): what the lists hold, in the words\n // the page shows — a bounce, a gateway block, a do-not-contact mark the\n // suppression lists do not carry.\n const stateRefusal = emailStateRefusal(readEmailState(recipient.record))\n if (stateRefusal) {\n return answer(res, refuse(409, stateRefusal, { reason: 'email-state' }))\n }\n\n if (!isEmailConfigured()) {\n return answer(\n res,\n refuse(501, 'Email is not configured on this deployment.', {\n reason: 'unconfigured',\n }),\n )\n }\n const identity = await hostSendingIdentity(hostId)\n if (identity.refusal) {\n return answer(\n res,\n refuse(409, identity.refusal.message, { reason: 'sending-identity' }),\n )\n }\n\n const now = new Date()\n /*\n * THE NEW-WORKSPACE RAMP, resolved for the ceiling below.\n *\n * A workspace past its first week graduates off the org record alone and\n * pays no read for this. One that is still on the ramp needs its\n * delivery history to know which step it has earned, and when that\n * history is unreadable the step is a floor rather than an answer.\n *\n * A floor is enough for a campaign, which defers what it cannot send\n * today and delivers it tomorrow. It is not enough here: this route\n * admits or refuses ONE message and has no third answer, so a ramp that\n * cannot say what a workspace has earned refuses and says so. The\n * refusal is transient by construction — it lasts as long as the window\n * read is failing — and it reaches only workspaces inside their first\n * week, since every other one graduated before the window was consulted.\n */\n const { ramp, degraded } = await readOrgEmailRamp({\n orgId,\n createdAt: (org as Record<string, unknown>)['createdAt'],\n now: now.getTime(),\n })\n if (degraded) {\n return answer(\n res,\n refuse(503, CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE, {\n reason: 'ramp-unavailable',\n }),\n )\n }\n\n // THE DAILY CAP (AGL-2611), taken as a slot rather than read as a\n // figure (AGL-2645): the transaction reads today's counter, judges it\n // with `checkCrmEmailQuota` against the ramp's share of the plan's day\n // and raises it in one step, so the count the cap enforces is the count\n // this send was admitted on.\n const claim = await reserveCrmEmailSend(firestore, orgId, org, now, ramp)\n if (!claim.ok) {\n const { quota } = claim\n // WHICH ceiling was reached, in the org's own terms. The reservation\n // answers with the number it judged, which is the plan's day unless\n // the ramp held this workspace below it — and a workspace shown a\n // figure under the one its plan page advertises is owed the reason.\n const rampBound =\n quota.included < resolveOrgEntitlements(org).crmEmailsPerDay\n return answer(\n res,\n refuse(\n 409,\n quota.included <= 0\n ? CRM_EMAIL_NOT_INCLUDED_MESSAGE\n : rampBound\n ? crmEmailRampReachedMessage(quota.included, quota.used)\n : crmEmailCapReachedMessage(quota.included),\n {\n reason: rampBound ? 'ramp' : 'quota',\n included: quota.included,\n used: quota.used,\n resetsAtMs: quota.resetsAt.getTime(),\n },\n ),\n )\n }\n\n // The row's id, minted before the send so the delivery webhook can find\n // it from the tags alone; written only once the message has left.\n const activityRef = newCrmActivityRef(firestore, orgId)\n let result: SendEmailResult\n try {\n result = await sendEmail({\n to: recipient.email,\n subject,\n text,\n sendingIdentity: identity,\n audience: 'tenant',\n context: CRM_EMAIL_CONTEXT,\n replyTo: sender.email,\n ...(sender.name ? { fromName: sender.name } : {}),\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: activityRef.id }),\n })\n } catch (error) {\n // The provider never answered, so no message left: the slot goes\n // back before the failure is reported.\n await releaseCrmEmailSend(firestore, claim.reservation)\n throw error\n }\n if (!result.sent) {\n await releaseCrmEmailSend(firestore, claim.reservation)\n return answer(res, sendRefusal(result))\n }\n\n const sentAtMs = Date.now()\n let logged = true\n /*\n * Who may list the row. Under a site, what a record created there is\n * stamped with. At the organization level the RECORD's own tokens, so\n * an email to a person another site captured shows on their timeline\n * under that site's hub — a row stamped with the sending site alone\n * would be invisible where the person is read.\n */\n const recordTokens = recipient.record['visibleTo']\n const visibleTo =\n routeScope.level === 'org' && Array.isArray(recordTokens) && recordTokens.length\n ? recordTokens.map(String)\n : crmScopeTokens(org as Record<string, unknown>, group)\n try {\n await writeCrmEmailActivity(\n activityRef,\n buildCrmEmailActivity({\n subject,\n body: text,\n to: recipient.email,\n atMs: sentAtMs,\n byUid: sender.uid,\n ...(sender.name ? { byName: sender.name } : {}),\n link: recipient.link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n // The message has left. A row that could not be written is a gap on\n // the timeline, not a failed send, and it is said so in the answer.\n console.error('[crm] email activity write failed', orgId, activityRef.id, error)\n logged = false\n }\n // The cap's count was taken with the reservation; the org's cost meter\n // for COGS is stamped after delivery, unable to fail the send.\n await recordEmailSends({\n scope: { kind: 'org', orgId },\n count: 1,\n sendClass: 'transactional',\n firestore,\n })\n // The org-level act, in the org's feed: the site variant's is the\n // timeline row itself, under the site the record page is on.\n if (routeScope.level === 'org') {\n await logOrgActivity(\n orgId,\n { uid: sender.uid, email: sender.email },\n 'Sent email',\n recipient.link.contactId\n ? { type: 'contact', id: recipient.link.contactId, name: recipient.email }\n : { type: 'lead', id: recipient.link.leadId, name: recipient.email },\n )\n }\n res.status(200).json({\n ok: true,\n activityId: activityRef.id,\n to: recipient.email,\n from: identity.from,\n logged,\n })\n } catch (error) {\n console.error('[crm] email-send failed', routeScope, error)\n res.status(500).json({ error: 'The email could not be sent.' })\n }\n}\n\nexport default crmEmailSendHandler\n"],"names":["buildCrmEmailActivity","contactCaptureHostIds","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","CRM_EMAIL_BODY_MAX","CRM_EMAIL_CONTEXT","CRM_EMAIL_SUBJECT_MAX","crmActivityLogHasRoom","crmEmailDeliveryTags","emailStateRefusal","crmMergeFieldsIn","crmScopeTokens","isOrgWideMember","normalizeContactEmail","readContactFacet","readEmailState","readMarketingBasis","renderCrmMergeFields","resolveOrgEntitlements","visibleToHost","isEmailConfigured","sendEmail","sendFailureReason","consentGroupForSite","consumeRateLimit","countCrmActivitiesForRecord","filterSendableForHost","firebaseAdmin","getHostDocAdmin","getOrgForHost","hostSendingIdentity","logOrgActivity","memberHasOrgPermission","newCrmActivityRef","orgDataCollectionForHost","readLeadForHost","readOrgEmailRamp","recordEmailSends","releaseCrmEmailSend","reserveCrmEmailSend","resolveOrgMembership","writeCrmEmailActivity","isRefusedIdToken","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_EMAIL_SENDS_PER_MINUTE","CRM_EMAIL_RATE_WINDOW_MS","CRM_EMAIL_NOT_INCLUDED_MESSAGE","crmEmailCapReachedMessage","included","toLocaleString","crmEmailRampReachedMessage","ceiling","used","CRM_EMAIL_RAMP_UNAVAILABLE_MESSAGE","CRM_EMAIL_SUPPRESSED_MESSAGE","CRM_EMAIL_DECLINED_MESSAGE","CRM_EMAIL_RATE_MESSAGE","CRM_EMAIL_PICK_SITE_MESSAGE","refuse","status","error","extra","ok","body","typed","value","max","String","trim","slice","authorizeSender","req","hostId","resolved","authorization","headers","idToken","startsWith","length","undefined","decoded","app","auth","verifyIdToken","console","catch","orgId","org","staff","member","membership","uid","reaches","Boolean","hostAccess","allowed","email","name","authorizeOrgSender","caller","needs","refusal","resolveRecipient","firestore","scope","ids","link","scoped","level","visible","tokens","contactId","siteHint","dealRecord","dealId","deal","collection","doc","deals","get","exists","id","data","contact","contacts","record","leadId","lead","converted","resolveSendingSite","hosts","reason","sendRefusal","result","detail","retryAtMs","Number","retryAfterSeconds","isFinite","Math","ceil","Date","now","CRM_EMAIL_EMPTY_AFTER_MERGE_MESSAGE","mergeContextFor","options","recipient","sender","groupId","fields","wantsSite","some","field","host","contactGroupId","site","answer","res","retry","setHeader","json","crmEmailSendHandler","method","routeScope","draftSubject","draftText","replace","suite","rate","limit","windowMs","resetMs","group","facet","companyId","merge","subject","text","orgRef","sendable","basis","stateRefusal","identity","message","ramp","degraded","createdAt","getTime","claim","quota","rampBound","crmEmailsPerDay","resetsAtMs","resetsAt","activityRef","to","sendingIdentity","audience","context","replyTo","fromName","tags","activityId","reservation","sent","sentAtMs","logged","recordTokens","visibleTo","Array","isArray","map","atMs","byUid","byName","kind","count","sendClass","type","from"],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { canManageOrg, CONTACT_ERASURE_REQUESTED_FIELD, normalizeContactEmail, PERSON_ERASURES_COLLECTION, personErasureConfirmationMatches, personErasureId } from "@aglyn/aglyn/server";
|
|
18
18
|
import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
19
|
-
import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataCollectionForHost, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { firebaseAdmin, getOrgForHost, logHostActivity, logOrgActivity, orgDataCollectionForHost, readLeadForHost, suppressEmailForHostErasure } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { resolveOrgPermissions } from "@aglyn/tenant-runtime/org-permissions";
|
|
21
21
|
import { FieldValue } from "firebase-admin/firestore";
|
|
22
22
|
import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.js";
|
|
@@ -171,8 +171,8 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
|
|
|
171
171
|
}
|
|
172
172
|
recordEmail = normalizeContactEmail(contact.get('email'));
|
|
173
173
|
} else {
|
|
174
|
-
const lead = await
|
|
175
|
-
if (!lead
|
|
174
|
+
const lead = await readLeadForHost(hostId, leadId);
|
|
175
|
+
if (!lead) {
|
|
176
176
|
res.status(404).json({
|
|
177
177
|
error: 'Unknown lead'
|
|
178
178
|
});
|
|
@@ -241,19 +241,37 @@ import { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from "./org-caller.
|
|
|
241
241
|
merge: true
|
|
242
242
|
});
|
|
243
243
|
const hostIds = await orgHostIds(firestore, orgId);
|
|
244
|
-
|
|
244
|
+
/*
|
|
245
|
+
* Suppression stays PER SITE — it is that site's sending list — while the
|
|
246
|
+
* lead marker below is written once, because the lead is one org row now
|
|
247
|
+
* (AGL-3275) rather than one row per site.
|
|
248
|
+
*/ for (const siteId of hostIds){
|
|
245
249
|
await suppressEmailForHostErasure({
|
|
246
250
|
hostId: siteId,
|
|
247
251
|
email: recordEmail
|
|
248
252
|
}).catch((error)=>{
|
|
249
253
|
console.error('[crm] erase-person suppression write failed', siteId, error);
|
|
250
254
|
});
|
|
251
|
-
|
|
252
|
-
|
|
255
|
+
}
|
|
256
|
+
/*
|
|
257
|
+
* THE ORG ROW, AND EVERY LEGACY ROW STILL STANDING.
|
|
258
|
+
*
|
|
259
|
+
* An erasure may not be the one caller that trusts the migration to be
|
|
260
|
+
* finished: until AGL-3276 has folded every site and AGL-3277 has deleted
|
|
261
|
+
* the fallback, a person can still be held at `hosts/{siteId}/leads`, and
|
|
262
|
+
* a marker that reached only the org row would leave that copy unmarked.
|
|
263
|
+
* So this marks whatever exists, in both homes, and the sweep costs one
|
|
264
|
+
* read per site exactly as it did before.
|
|
265
|
+
*/ const eraseLeadAt = async (ref, where)=>{
|
|
266
|
+
await ref.get().then((snapshot)=>snapshot.exists ? ref.update({
|
|
253
267
|
[CONTACT_ERASURE_REQUESTED_FIELD]: now
|
|
254
268
|
}) : undefined).catch((error)=>{
|
|
255
|
-
console.error('[crm] erase-person lead marker failed',
|
|
269
|
+
console.error('[crm] erase-person lead marker failed', where, error);
|
|
256
270
|
});
|
|
271
|
+
};
|
|
272
|
+
await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId);
|
|
273
|
+
for (const siteId of hostIds){
|
|
274
|
+
await eraseLeadAt(firestore.collection('hosts').doc(siteId).collection('leads').doc(key), siteId);
|
|
257
275
|
}
|
|
258
276
|
try {
|
|
259
277
|
const contacts = await firestore.collection('orgs').doc(orgId).collection('contacts').where('email', '==', recordEmail).get();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('leads')\n .doc(leadId)\n .get()\n if (!lead.exists) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n const lead = firestore.collection('hosts').doc(siteId).collection('leads').doc(key)\n await lead\n .get()\n .then((snapshot: { exists: boolean }) =>\n snapshot.exists ? lead.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now }) : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', siteId, error)\n })\n }\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","then","snapshot","update","contacts","where","docs","ref","target","type","id","add","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n canManageOrg,\n CONTACT_ERASURE_REQUESTED_FIELD,\n normalizeContactEmail,\n PERSON_ERASURES_COLLECTION,\n personErasureConfirmationMatches,\n personErasureId,\n type PersonErasureRequest,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n orgDataCollectionForHost,\n readLeadForHost,\n suppressEmailForHostErasure,\n} from '@aglyn/tenant-data-admin'\nimport { resolveOrgPermissions } from '@aglyn/tenant-runtime/org-permissions'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\n\n/** The route key, as `registerCrmConsoleApi` registers it. */\nexport const CRM_ERASE_PERSON_ROUTE = 'crm/erase-person'\n\nexport interface ErasePersonRequestBody {\n /**\n * The site the request is filed from. At the organization level\n * (AGL-2634) the record's own site, or absent for a contact no site has\n * captured; a LEAD always names one, because a lead lives under its site.\n */\n hostId?: string\n /** The organization, at the organization level (AGL-2634). */\n orgId?: string\n /**\n * At most one of these names the record the request is filed from. Under\n * a site exactly one does; at the organization level neither may, which\n * files the request by `email` alone (AGL-2839).\n */\n contactId?: string\n leadId?: string\n /**\n * The address as the admin typed it — the confirmation of a record's\n * address, or, filed by address alone, the person the request is about.\n */\n email: string\n /** Filed by address alone: the address typed a second time. */\n confirmEmail?: string\n}\n\nexport interface ErasePersonResponse {\n ok: true\n requestId: string\n /** When the request entered the queue. */\n pendingSinceMs: number\n /** True when a request for this person was already waiting; nothing was re-filed. */\n alreadyPending: boolean\n}\n\n/**\n * `POST /api/crm/erase-person` — file a privacy erasure for one person\n * (AGL-2623).\n *\n * ## Who may\n *\n * A workspace admin or owner — `canManageOrg` on the caller's org role —\n * and nobody else. Not a site editor with the data permission, who can\n * detach a contact from their own site but has no standing to remove the\n * person from every site in the workspace; and not staff acting alone,\n * because the workspace is the controller of this data and the instruction\n * has to come from it. Staff who are also admins of the workspace pass on\n * that role, as anyone would.\n *\n * ## What it does, and what it does not\n *\n * It files the request and closes the doors; it deletes nothing. The daily\n * erasure job executes the sweep, so that a workspace's person erasures and\n * its own erasure run through one job, one audit trail and one queue a\n * staff member can watch. What happens here, in order:\n *\n * 1. The record named by the body is read and its address is what the\n * request is about — the body's address must match it, typed, so an\n * admin who opened the wrong record is stopped by the confirmation\n * rather than by luck.\n * 2. The request document is written, or found already pending, in\n * which case nothing is re-filed and the caller is told so.\n * 3. Every site of the workspace gets its suppression row NOW, not when\n * the job runs: from this moment a capture cannot rebuild the person.\n * 4. The contact document and each site's lead are stamped with the\n * request time, so their pages can say \"erasure pending\" off the\n * document they already read.\n * 5. The site's activity feed and the platform audit log each get a row\n * that names the record by id and the person by hash, never by\n * address.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, hostId?, contactId | leadId, email }` from the org-level hub:\n * the same workspace admin, authorized by the org rather than through a\n * site they name, so a contact no site captured can be erased from the\n * page that lists it. The sweep is what it always was — every site of the\n * org, found by `orgId` — and the row goes to the org's feed. A lead still\n * names its site, because that is where a lead lives.\n *\n * ## By address alone (AGL-2839)\n *\n * `{ orgId, email, confirmEmail }` with no record, from the workspace's\n * Privacy settings: the same workspace admin, for any person the workspace\n * may hold — including one no page shows, since the CRM that lists records\n * is included from Starter and a Free workspace has none of it. Nothing is\n * read to file it: the sweep is keyed by the address, the markers are found\n * by it, and the typed address must match its second typing, which is the\n * confirmation a record's address gives the other variants. No plan is\n * asked on any variant: erasure is an obligation, not a CRM feature.\n */\nexport const crmErasePersonHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n const body: Partial<ErasePersonRequestBody> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const routeScope = readCrmRouteScope(body as Record<string, unknown>)\n const contactId = String(body.contactId ?? '').trim()\n const leadId = String(body.leadId ?? '').trim()\n const byAddress = routeScope?.level === 'org' && !contactId && !leadId\n if (!routeScope || (!byAddress && !contactId && !leadId) || (contactId && leadId)) {\n res.status(400).json({ error: 'Name the site and exactly one contact or lead' })\n return\n }\n const { hostId } = routeScope\n if (leadId && !hostId) {\n res.status(400).json({ error: 'Name the site the lead lives under' })\n return\n }\n const refusal = 'Only a workspace admin can erase a person from the workspace'\n\n try {\n let orgId: string\n let actor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'manage-org',\n refusal,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const membership = await resolveOrgPermissions(decoded.uid, { hostId })\n if (!canManageOrg(membership.role)) {\n res.status(403).json({ error: refusal })\n return\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved || resolved.orgId !== membership.orgId) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n orgId = resolved.orgId\n actor = { uid: decoded.uid, email: decoded.email ?? null }\n }\n const firestore = firebaseAdmin.app().firestore()\n\n let recordEmail: string | null = null\n if (byAddress) {\n recordEmail = normalizeContactEmail(body.email)\n } else if (contactId) {\n const contactsRef =\n routeScope.level === 'org'\n ? firestore.collection('orgs').doc(orgId).collection('contacts')\n : await orgDataCollectionForHost(hostId, 'contacts')\n const contact = await contactsRef.doc(contactId).get()\n if (!contact.exists) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n recordEmail = normalizeContactEmail(contact.get('email'))\n } else {\n const lead = await readLeadForHost(hostId, leadId)\n if (!lead) {\n res.status(404).json({ error: 'Unknown lead' })\n return\n }\n recordEmail = normalizeContactEmail(lead.get('email'))\n }\n const key = recordEmail ? personKey(recordEmail) : null\n if (!recordEmail || !key) {\n res.status(byAddress ? 400 : 422).json({\n error: byAddress\n ? 'Enter the email address of the person to erase'\n : 'This record has no usable email address to erase by',\n })\n return\n }\n if (byAddress && !personErasureConfirmationMatches(body.confirmEmail, recordEmail)) {\n res.status(400).json({\n error: 'Type the email address a second time, exactly, to confirm the erasure',\n })\n return\n }\n if (!byAddress && !personErasureConfirmationMatches(body.email, recordEmail)) {\n res.status(400).json({\n error: 'Type the record’s email address exactly to confirm the erasure',\n })\n return\n }\n\n const requestId = personErasureId(orgId, key)\n const requestRef = firestore.collection(PERSON_ERASURES_COLLECTION).doc(requestId)\n const existing = await requestRef.get()\n if (existing.exists && existing.get('status') === 'pending') {\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: Number(existing.get('pendingSinceMs') ?? 0),\n alreadyPending: true,\n }\n res.status(200).json(answer)\n return\n }\n\n const now = Date.now()\n const request: PersonErasureRequest = {\n orgId,\n personKey: key,\n status: 'pending',\n email: recordEmail,\n requestedAtMs: now,\n requestedByUid: actor.uid,\n ...(hostId ? { hostId } : {}),\n ...(contactId ? { contactId } : {}),\n ...(leadId ? { leadId } : {}),\n pendingSinceMs: now,\n }\n await requestRef.set(\n {\n ...request,\n // A request re-filed after a failed or completed run starts clean.\n erasedAtMs: FieldValue.delete(),\n result: FieldValue.delete(),\n failedAtMs: FieldValue.delete(),\n lastError: FieldValue.delete(),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n const hostIds = await orgHostIds(firestore, orgId)\n /*\n * Suppression stays PER SITE — it is that site's sending list — while the\n * lead marker below is written once, because the lead is one org row now\n * (AGL-3275) rather than one row per site.\n */\n for (const siteId of hostIds) {\n await suppressEmailForHostErasure({ hostId: siteId, email: recordEmail }).catch(\n (error: unknown) => {\n console.error('[crm] erase-person suppression write failed', siteId, error)\n },\n )\n }\n /*\n * THE ORG ROW, AND EVERY LEGACY ROW STILL STANDING.\n *\n * An erasure may not be the one caller that trusts the migration to be\n * finished: until AGL-3276 has folded every site and AGL-3277 has deleted\n * the fallback, a person can still be held at `hosts/{siteId}/leads`, and\n * a marker that reached only the org row would leave that copy unmarked.\n * So this marks whatever exists, in both homes, and the sweep costs one\n * read per site exactly as it did before.\n */\n const eraseLeadAt = async (\n ref: FirebaseFirestore.DocumentReference,\n where: string,\n ) => {\n await ref\n .get()\n .then((snapshot) =>\n snapshot.exists\n ? ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n : undefined,\n )\n .catch((error: unknown) => {\n console.error('[crm] erase-person lead marker failed', where, error)\n })\n }\n await eraseLeadAt(firestore.collection('orgs').doc(orgId).collection('leads').doc(key), orgId)\n for (const siteId of hostIds) {\n await eraseLeadAt(\n firestore.collection('hosts').doc(siteId).collection('leads').doc(key),\n siteId,\n )\n }\n try {\n const contacts = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('contacts')\n .where('email', '==', recordEmail)\n .get()\n for (const contact of contacts.docs) {\n await contact.ref.update({ [CONTACT_ERASURE_REQUESTED_FIELD]: now })\n }\n } catch (error) {\n console.error('[crm] erase-person contact marker failed', orgId, error)\n }\n\n // The feed the act was performed in: the site's under a site, the\n // org's at the organization level.\n const target = { type: contactId ? 'contact' : 'lead', id: contactId || leadId } as const\n if (routeScope.level === 'org') {\n // Filed by address, the line names no record — and never the address.\n await logOrgActivity(\n orgId,\n actor,\n 'Requested privacy erasure',\n byAddress ? { type: 'org' } : target,\n ).catch(() => undefined)\n } else {\n await logHostActivity(hostId, actor, 'Requested privacy erasure', target).catch(\n () => undefined,\n )\n }\n await firestore\n .collection('adminAudit')\n .add({\n actorUid: actor.uid,\n action: 'person.erasure-requested',\n target: `orgs/${orgId}/people/${key}`,\n before: null,\n after: {\n hostId: hostId || null,\n hosts: hostIds.length,\n from: byAddress ? 'address' : contactId ? 'contact' : 'lead',\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n const answer: ErasePersonResponse = {\n ok: true,\n requestId,\n pendingSinceMs: now,\n alreadyPending: false,\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] erase-person failed', routeScope, error)\n res.status(500).json({ error: 'The erasure could not be filed' })\n }\n}\n"],"names":["canManageOrg","CONTACT_ERASURE_REQUESTED_FIELD","normalizeContactEmail","PERSON_ERASURES_COLLECTION","personErasureConfirmationMatches","personErasureId","personKey","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","orgDataCollectionForHost","readLeadForHost","suppressEmailForHostErasure","resolveOrgPermissions","FieldValue","authorizeOrgCaller","orgHostIds","readCrmRouteScope","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","req","res","body","method","setHeader","status","json","error","authorization","String","headers","idToken","startsWith","slice","length","undefined","JSON","parse","routeScope","contactId","trim","leadId","byAddress","level","hostId","refusal","orgId","actor","caller","needs","ok","uid","email","decoded","app","auth","verifyIdToken","membership","role","resolved","firestore","recordEmail","contactsRef","collection","doc","contact","get","exists","lead","key","confirmEmail","requestId","requestRef","existing","answer","pendingSinceMs","Number","alreadyPending","now","Date","request","requestedAtMs","requestedByUid","set","erasedAtMs","delete","result","failedAtMs","lastError","updatedAt","serverTimestamp","merge","hostIds","siteId","catch","console","eraseLeadAt","ref","where","then","snapshot","update","contacts","docs","target","type","id","add","actorUid","action","before","after","hosts","from","at"],"mappings":"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"}
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
*/ import { contactCampaignFieldPath, readCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
17
17
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
18
18
|
import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
|
|
19
|
+
import { readLeadForHost } from "@aglyn/tenant-data-admin/server/host-visitor-records";
|
|
19
20
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
|
+
import { CAMPAIGN_FILING_CARRY_BY_NAME } from "../model/campaign-filing-activity.js";
|
|
22
|
+
import { fileCampaignFilingActivities, siteCampaignRefs } from "./campaign-filing-activity.js";
|
|
20
23
|
/**
|
|
21
24
|
* WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).
|
|
22
25
|
*
|
|
@@ -38,8 +41,8 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
38
41
|
* by hand.
|
|
39
42
|
*/ export async function carryLeadCampaignsToContact(firestore, request) {
|
|
40
43
|
try {
|
|
41
|
-
const lead = await
|
|
42
|
-
const campaignIds = readCampaignIds(lead
|
|
44
|
+
const lead = await readLeadForHost(request.hostId, request.leadId);
|
|
45
|
+
const campaignIds = readCampaignIds(lead ? lead.data() : null);
|
|
43
46
|
if (!campaignIds.length) return {
|
|
44
47
|
campaigns: 0
|
|
45
48
|
};
|
|
@@ -48,6 +51,29 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
48
51
|
[contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(...campaignIds),
|
|
49
52
|
updatedAt: FieldValue.serverTimestamp()
|
|
50
53
|
});
|
|
54
|
+
/*
|
|
55
|
+
* The carry on the contact's Activity (AGL-3274): one "Filed under"
|
|
56
|
+
* per campaign, by the conversion rather than a member, keyed once per
|
|
57
|
+
* lead and contact so a door that converts twice files once. After the
|
|
58
|
+
* membership has landed, and never failing the carry that is done.
|
|
59
|
+
*/ await fileCampaignFilingActivities(firestore, {
|
|
60
|
+
orgId: request.orgId,
|
|
61
|
+
hostId: request.hostId,
|
|
62
|
+
link: {
|
|
63
|
+
contactId: request.contactId
|
|
64
|
+
},
|
|
65
|
+
action: 'filed',
|
|
66
|
+
// Named where the containers answer; by id where a read fails, so a
|
|
67
|
+
// name lookup can never unmake a carry that has landed.
|
|
68
|
+
campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(()=>campaignIds.map((id)=>({
|
|
69
|
+
id,
|
|
70
|
+
name: id
|
|
71
|
+
}))),
|
|
72
|
+
atMs: Date.now(),
|
|
73
|
+
byUid: '',
|
|
74
|
+
byName: CAMPAIGN_FILING_CARRY_BY_NAME,
|
|
75
|
+
dedupeKey: `carry:${request.leadId}:${request.contactId}`
|
|
76
|
+
});
|
|
51
77
|
return {
|
|
52
78
|
campaigns: campaignIds.length
|
|
53
79
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/lead-campaign-carry.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n contactCampaignFieldPath,\n readCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { readLeadForHost } from '@aglyn/tenant-data-admin/server/host-visitor-records'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CAMPAIGN_FILING_CARRY_BY_NAME } from '../model/campaign-filing-activity'\nimport { fileCampaignFilingActivities, siteCampaignRefs } from './campaign-filing-activity'\n\n/**\n * WHAT A LEAD'S CAMPAIGNS BECOME WHEN IT CONVERTS (AGL-3254).\n *\n * A lead is filed under a site's campaigns at the top of its own document;\n * a contact holds the same membership inside the site's consent-group\n * facet, because a contact is shared across the org and a campaign is one\n * site's. When the lead becomes a contact, the campaigns go with it — added\n * to what the contact already carries there with `arrayUnion`, never in\n * place of it — so the campaign's page goes on listing the person after\n * they became a contact, and a lead a sequence enrolled under a campaign\n * does not leave the campaign by converting.\n *\n * The CRM's own module, because a lead and a contact are the CRM's records\n * (docs/PACKAGES.md rule 3): it runs as the CRM's share of the platform's\n * lead-conversion seam, which every door that converts a lead — the\n * dialog, the REST API, a sign-up, a purchase — reaches through\n * `handOffLeadRecords`. Never throws: the conversion has happened, and a\n * membership that could not be carried is one the contact's page can pick\n * by hand.\n */\nexport async function carryLeadCampaignsToContact(\n firestore: FirebaseFirestore.Firestore,\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n try {\n const lead = await readLeadForHost(request.hostId, request.leadId)\n const campaignIds = readCampaignIds(\n lead ? (lead.data() as Record<string, unknown>) : null,\n )\n if (!campaignIds.length) return { campaigns: 0 }\n const group = await consentGroupForSite(request.hostId)\n await firestore\n .collection('orgs')\n .doc(request.orgId)\n .collection('contacts')\n .doc(request.contactId)\n .update({\n [contactCampaignFieldPath(group.groupId)]: FieldValue.arrayUnion(\n ...campaignIds,\n ),\n updatedAt: FieldValue.serverTimestamp(),\n })\n /*\n * The carry on the contact's Activity (AGL-3274): one \"Filed under\"\n * per campaign, by the conversion rather than a member, keyed once per\n * lead and contact so a door that converts twice files once. After the\n * membership has landed, and never failing the carry that is done.\n */\n await fileCampaignFilingActivities(firestore, {\n orgId: request.orgId,\n hostId: request.hostId,\n link: { contactId: request.contactId },\n action: 'filed',\n // Named where the containers answer; by id where a read fails, so a\n // name lookup can never unmake a carry that has landed.\n campaigns: await siteCampaignRefs(firestore, request.hostId, campaignIds).catch(() =>\n campaignIds.map((id) => ({ id, name: id })),\n ),\n atMs: Date.now(),\n byUid: '',\n byName: CAMPAIGN_FILING_CARRY_BY_NAME,\n dedupeKey: `carry:${request.leadId}:${request.contactId}`,\n })\n return { campaigns: campaignIds.length }\n } catch (error) {\n console.error(\n '[crm] the lead’s campaigns could not be carried to the contact',\n request.hostId,\n request.leadId,\n error,\n )\n return { campaigns: null }\n }\n}\n\n/**\n * The carry as the lead-conversion seam calls it: on the Admin SDK's own\n * Firestore. The SDK is resolved HERE, not in `declarations.server.ts`, for\n * two reasons that point the same way. That file must stay light — it runs\n * at the boot of every server process — so it defers this module and must\n * not import the data layer itself. And a library the plugin imports\n * statically in thirty files cannot also be lazy-loaded in one:\n * `enforce-module-boundaries` refuses every static import of a library the\n * project lazy-loads anywhere. This module is the deferred one; the SDK\n * comes with it, the way `capture-contact` brings its own.\n */\nexport function carryLeadCampaignsOnConversion(\n request: Pick<\n PluginLeadConversionRequest,\n 'orgId' | 'hostId' | 'leadId' | 'contactId'\n >,\n): Promise<PluginLeadConversionReport> {\n return carryLeadCampaignsToContact(firebaseAdmin.app().firestore(), request)\n}\n"],"names":["contactCampaignFieldPath","readCampaignIds","firebaseAdmin","consentGroupForSite","readLeadForHost","FieldValue","CAMPAIGN_FILING_CARRY_BY_NAME","fileCampaignFilingActivities","siteCampaignRefs","carryLeadCampaignsToContact","firestore","request","lead","hostId","leadId","campaignIds","data","length","campaigns","group","collection","doc","orgId","contactId","update","groupId","arrayUnion","updatedAt","serverTimestamp","link","action","catch","map","id","name","atMs","Date","now","byUid","byName","dedupeKey","error","console","carryLeadCampaignsOnConversion","app"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,6CAA4C;AAKnD,SAASC,aAAa,QAAQ,iDAAgD;AAC9E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,eAAe,QAAQ,uDAAsD;AACtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,6BAA6B,QAAQ,uCAAmC;AACjF,SAASC,4BAA4B,EAAEC,gBAAgB,QAAQ,gCAA4B;AAE3F;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,eAAeC,4BACpBC,SAAsC,EACtCC,OAGC;IAED,IAAI;QACF,MAAMC,OAAO,MAAMR,gBAAgBO,QAAQE,MAAM,EAAEF,QAAQG,MAAM;QACjE,MAAMC,cAAcd,gBAClBW,OAAQA,KAAKI,IAAI,KAAiC;QAEpD,IAAI,CAACD,YAAYE,MAAM,EAAE,OAAO;YAAEC,WAAW;QAAE;QAC/C,MAAMC,QAAQ,MAAMhB,oBAAoBQ,QAAQE,MAAM;QACtD,MAAMH,UACHU,UAAU,CAAC,QACXC,GAAG,CAACV,QAAQW,KAAK,EACjBF,UAAU,CAAC,YACXC,GAAG,CAACV,QAAQY,SAAS,EACrBC,MAAM,CAAC;YACN,CAACxB,yBAAyBmB,MAAMM,OAAO,EAAE,EAAEpB,WAAWqB,UAAU,IAC3DX;YAELY,WAAWtB,WAAWuB,eAAe;QACvC;QACF;;;;;KAKC,GACD,MAAMrB,6BAA6BG,WAAW;YAC5CY,OAAOX,QAAQW,KAAK;YACpBT,QAAQF,QAAQE,MAAM;YACtBgB,MAAM;gBAAEN,WAAWZ,QAAQY,SAAS;YAAC;YACrCO,QAAQ;YACR,oEAAoE;YACpE,wDAAwD;YACxDZ,WAAW,MAAMV,iBAAiBE,WAAWC,QAAQE,MAAM,EAAEE,aAAagB,KAAK,CAAC,IAC9EhB,YAAYiB,GAAG,CAAC,CAACC,KAAQ,CAAA;wBAAEA;wBAAIC,MAAMD;oBAAG,CAAA;YAE1CE,MAAMC,KAAKC,GAAG;YACdC,OAAO;YACPC,QAAQjC;YACRkC,WAAW,CAAC,MAAM,EAAE7B,QAAQG,MAAM,CAAC,CAAC,EAAEH,QAAQY,SAAS,EAAE;QAC3D;QACA,OAAO;YAAEL,WAAWH,YAAYE,MAAM;QAAC;IACzC,EAAE,OAAOwB,OAAO;QACdC,QAAQD,KAAK,CACX,kEACA9B,QAAQE,MAAM,EACdF,QAAQG,MAAM,EACd2B;QAEF,OAAO;YAAEvB,WAAW;QAAK;IAC3B;AACF;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASyB,+BACdhC,OAGC;IAED,OAAOF,4BAA4BP,cAAc0C,GAAG,GAAGlC,SAAS,IAAIC;AACtE"}
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
* because a lead entered by hand is suite work: the capture doors that fill
|
|
46
46
|
* a Free workspace's leads do not come through here.
|
|
47
47
|
*/
|
|
48
|
-
import { type CrmLeadProfilePatch, type CrmLeadStatus, type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
48
|
+
import { type CrmCustomValue, type CrmLeadProfilePatch, type CrmLeadStatus, type PluginApiHandler } from '@aglyn/aglyn/server';
|
|
49
|
+
import type { CampaignFilingRef } from '../model/campaign-filing-activity';
|
|
49
50
|
/** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */
|
|
50
51
|
export declare const LEAD_MANUAL_SOURCE = "manual";
|
|
51
52
|
/** What the New lead drawer posts. */
|
|
@@ -65,17 +66,27 @@ export interface LeadCreateRequest {
|
|
|
65
66
|
notes?: string;
|
|
66
67
|
/** The site's campaigns to file the lead under (AGL-3254), by container id. */
|
|
67
68
|
campaignIds?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* The org's custom LEAD fields (AGL-3272), keyed by each definition's
|
|
71
|
+
* `key`. Judged against the definitions whose `object` is `lead`, so a
|
|
72
|
+
* contact field of the same name cannot be written through this door.
|
|
73
|
+
*/
|
|
74
|
+
custom?: Record<string, CrmCustomValue>;
|
|
68
75
|
}
|
|
69
76
|
/** The sentence a campaign that is not the site's is refused with, under the field. */
|
|
70
77
|
export declare const LEAD_CAMPAIGN_REFUSAL = "One of the campaigns picked isn't a campaign of this site any more. Pick it again.";
|
|
71
78
|
/**
|
|
72
|
-
* The
|
|
79
|
+
* The campaigns a request may file a lead under: the site's own live
|
|
73
80
|
* containers, and nothing else. A request can claim any id, and a lead
|
|
74
81
|
* filed under another site's campaign — or one the console soft-deleted —
|
|
75
|
-
* would sit on a page nobody at this site can open.
|
|
82
|
+
* would sit on a page nobody at this site can open. Answered with the
|
|
83
|
+
* names as they stand, for the timeline entries the filing writes
|
|
84
|
+
* (AGL-3274).
|
|
76
85
|
*
|
|
77
|
-
* @returns the clean ids, or `null` when one
|
|
86
|
+
* @returns the clean ids with their names, or `null` when one is not the site's.
|
|
78
87
|
*/
|
|
88
|
+
export declare function siteCampaigns(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<CampaignFilingRef[] | null>;
|
|
89
|
+
/** The ids alone — see {@link siteCampaigns}. */
|
|
79
90
|
export declare function siteCampaignIds(hostRef: FirebaseFirestore.DocumentReference, raw: unknown): Promise<string[] | null>;
|
|
80
91
|
/** What the route answers on success. */
|
|
81
92
|
export interface LeadCreateResponse {
|