@aglyn/plugins-crm 1.0.0-beta.222 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/crm-next-activity-column.js +1 -1
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +2 -1
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/declarations.server.js +5 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/contact-record.js +2 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-next-activity.d.ts +107 -0
- package/src/lib/model/crm-next-activity.js +140 -0
- package/src/lib/model/crm-next-activity.js.map +1 -0
- package/src/lib/model/crm-recipes.js +33 -18
- package/src/lib/model/crm-recipes.js.map +1 -1
- package/src/lib/model/crm-task-reminders.d.ts +152 -0
- package/src/lib/model/crm-task-reminders.js +161 -0
- package/src/lib/model/crm-task-reminders.js.map +1 -0
- package/src/lib/model/next-activity.d.ts +1 -1
- package/src/lib/model/next-activity.js.map +1 -1
- package/src/lib/server/api-v1/crm-tasks.js +4 -2
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -1
- package/src/lib/server/assign-contact-owner.d.ts +27 -1
- package/src/lib/server/assign-contact-owner.js +136 -30
- package/src/lib/server/assign-contact-owner.js.map +1 -1
- package/src/lib/server/automation-steps.d.ts +2 -1
- package/src/lib/server/automation-steps.js +210 -13
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/capture-contact.d.ts +17 -0
- package/src/lib/server/capture-contact.js +31 -6
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +118 -0
- package/src/lib/server/crm-email-activity.js +148 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +1 -1
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/email-send.js +2 -1
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +1 -1
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/next-activity-routes.js +2 -1
- package/src/lib/server/next-activity-routes.js.map +1 -1
- package/src/lib/server/recipe-routes.js +7 -1
- package/src/lib/server/recipe-routes.js.map +1 -1
- package/src/lib/server/record-timeline.js +26 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +2 -1
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.js +4 -2
- package/src/lib/server/task-routes.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/recipe-routes.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\n/**\n * THE RECIPES, INSTALLED FROM THE ORGANIZATION (AGL-2639).\n *\n * On a site, a recipe opens the Actions editor prefilled and writes nothing\n * until the person saves — the editor is the writer, and the site's own\n * role is the authority. The organization-level CRM hub has no editor: it\n * mounts over every site with no site of its own, and an org running five\n * sites installed \"Welcome a new lead\" five times by hand. These two routes\n * are the org hub's door to a site's automations.\n *\n * `crm/recipe-install` writes the action a recipe builds into ONE site's\n * `hosts/{hostId}/actions`, as the site editor would have saved it — the\n * same builder, the same validator, the same stored shape — and refuses a\n * second install of the same recipe on the same site by the stamp the\n * builder puts on it. A server route rather than a client write for three\n * reasons the browser cannot supply: the document is created by a server\n * on every plan (the rules close `actions` to client creates, and the\n * per-site cap is counted here); the stamp is the server's word, not the\n * caller's; and the site is checked to be one the caller may manage —\n * at the org level, a site OF THE ORG the caller holds org-wide reach in,\n * so a host id typed into the body cannot reach a site under another\n * workspace.\n *\n * `crm/recipe-status` reads the stamps back for every site of the org, so\n * the card can say which sites carry which recipe. An action with no stamp\n * at all — saved before the stamp existed — is counted, not ignored: it\n * may well be a recipe, and the reader is told the site \"may already have\n * it\" rather than shown a clean slate.\n *\n * Both routes serve the site variant too (`hostId` alone, authorized by the\n * host role that creates actions today), so a site-level caller gets the\n * same answers; the org variant (`orgId` in the body) is the one the org\n * hub uses.\n */\n\nimport {\n ACTIONS_MAX_PER_HOST,\n type AglynOrganization,\n checkEntitlement,\n createResourceUid,\n hostRoleCanWrite,\n planLabelGrantingFeature,\n type PluginApiHandler,\n type PluginApiRequest,\n siteInteractionDocument,\n} from '@aglyn/aglyn/server'\nimport { interactionRecipeStamp } from '@aglyn/aglyn/plugin-manager/interaction-recipes'\nimport { validateStoredInteraction } from '@aglyn/aglyn/plugin-manager/interaction-step-checks'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_API_ROUTES,\n type CrmRecipeInstallRequest,\n type CrmRecipeInstallResult,\n type CrmRecipeSiteStatus,\n} from '../constants/api-routes'\nimport { crmActionRecipe, type CrmActionRecipeId } from '../model/crm-recipes'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CRM_RECIPE_INSTALL_ROUTE = CRM_API_ROUTES.recipeInstall\nexport const CRM_RECIPE_STATUS_ROUTE = CRM_API_ROUTES.recipeStatus\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/** A verified caller who may write a site's actions, and the site's org. */\ninterface SiteWriter {\n ok: true\n uid: string\n email: string | null\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n host: FirebaseFirestore.DocumentSnapshot\n}\n\nconst INSTALL_REFUSAL =\n 'Installing a recipe requires the data permission across the whole workspace'\nconst STATUS_REFUSAL =\n 'Reading which sites carry a recipe requires the data permission across the whole workspace'\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\n/**\n * The site variant's authority: the role on the host document that creates\n * an action through `/api/hosts/resources` today. Staff pass, as they pass\n * that route. Answers the host snapshot it read, so the caller reads it\n * once.\n */\nasync function authorizeSiteWriter(\n req: PluginApiRequest,\n hostId: string,\n): Promise<SiteWriter | 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 { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: 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 { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the recipe site writer could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const host = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n const role = (host.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) return { ok: false, status: 404, error: 'Unknown site' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n staff,\n orgId: resolved.orgId,\n org: resolved.org,\n host,\n }\n}\n\n/**\n * The org variant's site check: the host document, when it belongs to the\n * organization the caller was admitted to. A site under another workspace\n * — or none — reads as unknown, so a probe learns nothing from the answer.\n */\nasync function siteOfOrg(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orgId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists && host.get('orgId') === orgId ? host : null\n}\n\n/** How a site reads in a feed line: its display name, its subdomain, its id. */\nfunction siteLabel(host: FirebaseFirestore.DocumentSnapshot): string {\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n host.id\n )\n}\n\n/**\n * The stamps a site's LIVE actions carry, read with a field mask: the\n * install's duplicate check and the status sweep ask the same question,\n * and neither needs a trigger or a step list to answer it. A soft-deleted\n * action (`deletedAt`) is a freed slot and counts for nothing.\n */\nfunction readSiteStamps(rows: FirebaseFirestore.QuerySnapshot): {\n installed: Map<CrmActionRecipeId, string>\n unstamped: number\n live: number\n} {\n const installed = new Map<CrmActionRecipeId, string>()\n let unstamped = 0\n let live = 0\n for (const row of rows.docs) {\n if (row.get('deletedAt') != null) continue\n live += 1\n const stamp = interactionRecipeStamp(row.data())\n if (stamp === undefined) unstamped += 1\n else if (stamp && !installed.has(stamp as CrmActionRecipeId)) {\n installed.set(stamp as CrmActionRecipeId, row.id)\n }\n }\n return { installed, unstamped, live }\n}\n\nconst stampsQuery = (host: FirebaseFirestore.DocumentReference) =>\n host.collection('actions').select('recipe', 'deletedAt')\n\n/**\n * `POST /api/crm/recipe-install`\n *\n * The body is a CrmRecipeInstallRequest: the site, the recipe, the form a\n * recipe that needs one is keyed on, and the org at the organization level.\n * Answers 201 with a CrmRecipeInstallResult once the action is written, and\n * 409 when a live action on the site already carries the stamp — naming it,\n * so the caller can say \"already installed\" rather than install it twice.\n *\n * The write is one transaction: the stamps are read, the duplicate and the\n * per-site cap are judged, and the document is created — all inside it, so\n * two installs racing for the same site cannot both land.\n */\nexport const crmRecipeInstallHandler: 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: Partial<CrmRecipeInstallRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body as Record<string, unknown>)\n const hostId = scope?.hostId ?? ''\n if (!scope || !hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const recipe = crmActionRecipe(typed(body.recipeId, 64))\n if (!recipe) {\n res.status(400).json({ error: 'Pick a recipe' })\n return\n }\n const formId = typed(body.formId, 200)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n let writer: SiteWriter\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: INSTALL_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n const host = await siteOfOrg(firestore, hostId, caller.orgId)\n if (!host) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n writer = {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org,\n host,\n }\n } else {\n const caller = await authorizeSiteWriter(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n writer = caller\n }\n\n // A recipe is a CRM automation first (AGL-2787): the suite is asked\n // before the actions builder, so a plan without either is told about\n // the one the recipe belongs to.\n const suite = crmSuiteRefusal(writer.org, 'A CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The gate the site's own Recipes menu applies, judged here from the\n // org document rather than trusted from the console: a recipe is an\n // action, and the plan that carries one carries the other.\n if (!checkEntitlement(writer.org, 'actions')) {\n res.status(403).json({\n error:\n `The actions builder requires the ${planLabelGrantingFeature('actions') ?? 'Pro'} ` +\n 'plan — see Billing to upgrade',\n })\n return\n }\n\n const hostRef = writer.host.ref\n let form: { id: string; name: string } | undefined\n if (recipe.picks?.kind === 'form') {\n if (!formId) {\n res.status(400).json({ error: 'Pick one of the site’s forms' })\n return\n }\n const snapshot = await hostRef.collection('forms').doc(formId).get()\n if (!snapshot.exists) {\n res.status(404).json({ error: 'Unknown form' })\n return\n }\n // An archived form collects nothing, so a recipe keyed on it would\n // never fire; the name falls back to the id the way every form list's does.\n if (snapshot.get('archivedAt') != null) {\n res.status(400).json({ error: 'That form is archived and collects nothing' })\n return\n }\n form = {\n id: snapshot.id,\n name: String(snapshot.get('displayName') ?? '').trim() || snapshot.id,\n }\n }\n\n const action = recipe.build(form ? { picked: form } : undefined)\n // The automation editor's own checks of every step, registered from its\n // declarations: a recipe installs only what that editor would save.\n const problem = validateStoredInteraction(action)\n if (problem) {\n res.status(400).json({ error: problem })\n return\n }\n\n const actionId = createResourceUid()\n const outcome = await firestore.runTransaction(async (tx) => {\n const stamps = readSiteStamps(await tx.get(stampsQuery(hostRef)))\n const existing = stamps.installed.get(recipe.id)\n if (existing) {\n return {\n status: 409,\n error: 'Already installed on this site',\n actionId: existing,\n }\n }\n if (stamps.live >= ACTIONS_MAX_PER_HOST) {\n return {\n status: 403,\n error: `interactions and actions are capped at ${ACTIONS_MAX_PER_HOST} per site`,\n }\n }\n tx.create(hostRef.collection('actions').doc(actionId), {\n ...siteInteractionDocument(action),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: writer.uid,\n })\n return null\n })\n if (outcome) {\n res.status(outcome.status).json({\n error: outcome.error,\n ...(outcome.actionId ? { actionId: outcome.actionId } : {}),\n })\n return\n }\n\n const actor = { uid: writer.uid, email: writer.email }\n await logHostActivity(hostId, actor, 'Installed recipe', {\n type: 'content',\n id: actionId,\n name: action.name,\n })\n // The org hub's own feed line, for the act it performed (AGL-2634).\n if (scope.level === 'org') {\n await logOrgActivity(\n writer.orgId,\n actor,\n `Installed the “${recipe.title}” recipe on ${siteLabel(writer.host)}`,\n { type: 'host', id: hostId, name: siteLabel(writer.host) },\n )\n }\n const answer: CrmRecipeInstallResult = {\n ok: true,\n actionId,\n name: action.name,\n recipeId: recipe.id,\n }\n res.status(201).json(answer)\n } catch (error) {\n console.error('[crm] recipe install failed', hostId, error)\n res.status(500).json({ error: 'The recipe could not be installed' })\n }\n}\n\n/**\n * `POST /api/crm/recipe-status`\n *\n * The body names the organization (`orgId`) for every one of its sites, or\n * one site (`hostId`) for that site alone. Answers `ok: true` and `sites`,\n * one CrmRecipeSiteStatus per site swept.\n */\nexport const crmRecipeStatusHandler: 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: Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing orgId or hostId' })\n return\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n let orgId: string | null = null\n let org: unknown\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: STATUS_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n } else {\n const caller = await authorizeSiteWriter(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n org = caller.org\n }\n // The plan after the caller, before any site is read (AGL-2851).\n const suite = crmSuiteRefusal(org, 'Reading which sites carry a CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const hostIds = orgId ? await orgHostIds(firestore, orgId) : [scope.hostId]\n const sites = await Promise.all(\n hostIds.map(async (hostId): Promise<CrmRecipeSiteStatus> => {\n const stamps = readSiteStamps(\n await stampsQuery(firestore.collection('hosts').doc(hostId)).get(),\n )\n return {\n hostId,\n installed: [...stamps.installed.keys()],\n unstamped: stamps.unstamped,\n }\n }),\n )\n res.status(200).json({ ok: true, sites })\n } catch (error) {\n console.error('[crm] recipe status failed', error)\n res.status(500).json({ error: 'The recipe status could not be read' })\n }\n}\n"],"names":["ACTIONS_MAX_PER_HOST","checkEntitlement","createResourceUid","hostRoleCanWrite","planLabelGrantingFeature","siteInteractionDocument","interactionRecipeStamp","validateStoredInteraction","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","isRefusedIdToken","FieldValue","CRM_API_ROUTES","crmActionRecipe","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","recipeInstall","CRM_RECIPE_STATUS_ROUTE","recipeStatus","INSTALL_REFUSAL","STATUS_REFUSAL","typed","value","max","String","trim","slice","authorizeSiteWriter","req","hostId","host","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","firestore","collection","doc","get","exists","role","uid","resolved","email","orgId","org","siteOfOrg","siteLabel","id","readSiteStamps","rows","installed","Map","unstamped","live","row","docs","stamp","data","has","set","stampsQuery","select","crmRecipeInstallHandler","res","method","setHeader","json","body","JSON","parse","scope","recipe","recipeId","formId","writer","level","caller","needs","refusal","suite","hostRef","ref","form","picks","kind","snapshot","name","action","build","picked","problem","actionId","outcome","runTransaction","tx","stamps","existing","create","createdAt","serverTimestamp","updatedAt","createdBy","actor","type","title","answer","crmRecipeStatusHandler","hostIds","sites","Promise","all","map","keys"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCC,GAED,SACEA,oBAAoB,EAEpBC,gBAAgB,EAChBC,iBAAiB,EACjBC,gBAAgB,EAChBC,wBAAwB,EAGxBC,uBAAuB,QAClB,sBAAqB;AAC5B,SAASC,sBAAsB,QAAQ,kDAAiD;AACxF,SAASC,yBAAyB,QAAQ,sDAAqD;AAC/F,SACEC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,cAAc,QACT,2BAA0B;AACjC,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SACEC,cAAc,QAIT,6BAAyB;AAChC,SAASC,eAAe,QAAgC,0BAAsB;AAC9E,SAASC,kBAAkB,EAAEC,UAAU,EAAEC,iBAAiB,QAAQ,kBAAc;AAChF,SAASC,eAAe,QAAQ,kBAAc;AAE9C,OAAO,MAAMC,2BAA2BN,eAAeO,aAAa,CAAA;AACpE,OAAO,MAAMC,0BAA0BR,eAAeS,YAAY,CAAA;AAelE,MAAMC,kBACJ;AACF,MAAMC,iBACJ;AAEF,SAASC,MAAMC,KAAc,EAAEC,GAAW;IACxC,OAAOC,OAAOF,gBAAAA,QAAS,IACpBG,IAAI,GACJC,KAAK,CAAC,GAAGH;AACd;AAEA;;;;;CAKC,GACD,eAAeI,oBACbC,GAAqB,EACrBC,MAAc;QAEeD,4BAuBfE,WASLC;IAhCT,MAAMC,gBAAgBR,QAAOI,6BAAAA,IAAIK,OAAO,CAACD,aAAa,YAAzBJ,6BAA6B;IAC1D,MAAMM,UAAUF,cAAcG,UAAU,CAAC,aACrCH,cAAcN,KAAK,CAAC,UAAUU,MAAM,IACpCC;IACJ,IAAI,CAACH,SAAS,OAAO;QAAEI,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAkB;IACxE,IAAIT;IACJ,IAAI;QACFA,UAAU,MAAM5B,cAAcsC,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACT;IAC3D,EAAE,OAAOM,OAAO;QACd,sEAAsE;QACtE,mCAAmC;QACnC,IAAIjC,iBAAiBiC,QAAQ,OAAO;YAAEF,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAAkB;QACvFI,QAAQJ,KAAK,CAAC,sDAAsDA;QACpE,OAAO;YAAEF,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAA+C;IACzF;IACA,MAAMK,QAAQd,QAAQc,KAAK,KAAK;IAChC,MAAMf,OAAO,MAAM3B,cAChBsC,GAAG,GACHK,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACnB,QACJoB,GAAG;IACN,IAAI,CAACnB,KAAKoB,MAAM,EAAE,OAAO;QAAEZ,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAe;IACzE,MAAMW,OAAO,EAACrB,YAAAA,KAAKmB,GAAG,CAAC,0BAATnB,YAA2B,CAAC,EAAE,CAACC,QAAQqB,GAAG,CAAC;IACzD,IAAI,CAACP,SAAS,CAAC/C,iBAAiBqD,OAAO;QACrC,OAAO;YAAEb,IAAI;YAAOC,QAAQ;YAAKC,OAAO;QAAmC;IAC7E;IACA,MAAMa,WAAW,MAAMjD,cAAcyB;IACrC,IAAI,CAACwB,UAAU,OAAO;QAAEf,IAAI;QAAOC,QAAQ;QAAKC,OAAO;IAAe;IACtE,OAAO;QACLF,IAAI;QACJc,KAAKrB,QAAQqB,GAAG;QAChBE,KAAK,GAAEvB,iBAAAA,QAAQuB,KAAK,YAAbvB,iBAAiB;QACxBc;QACAU,OAAOF,SAASE,KAAK;QACrBC,KAAKH,SAASG,GAAG;QACjB1B;IACF;AACF;AAEA;;;;CAIC,GACD,eAAe2B,UACbX,SAAsC,EACtCjB,MAAc,EACd0B,KAAa;IAEb,MAAMzB,OAAO,MAAMgB,UAAUC,UAAU,CAAC,SAASC,GAAG,CAACnB,QAAQoB,GAAG;IAChE,OAAOnB,KAAKoB,MAAM,IAAIpB,KAAKmB,GAAG,CAAC,aAAaM,QAAQzB,OAAO;AAC7D;AAEA,8EAA8E,GAC9E,SAAS4B,UAAU5B,IAAwC;QAEhDA,WACAA;IAFT,OACEN,QAAOM,YAAAA,KAAKmB,GAAG,CAAC,0BAATnB,YAA2B,IAAIL,IAAI,MAC1CD,QAAOM,aAAAA,KAAKmB,GAAG,CAAC,wBAATnB,aAAyB,IAAIL,IAAI,MACxCK,KAAK6B,EAAE;AAEX;AAEA;;;;;CAKC,GACD,SAASC,eAAeC,IAAqC;IAK3D,MAAMC,YAAY,IAAIC;IACtB,IAAIC,YAAY;IAChB,IAAIC,OAAO;IACX,KAAK,MAAMC,OAAOL,KAAKM,IAAI,CAAE;QAC3B,IAAID,IAAIjB,GAAG,CAAC,gBAAgB,MAAM;QAClCgB,QAAQ;QACR,MAAMG,QAAQnE,uBAAuBiE,IAAIG,IAAI;QAC7C,IAAID,UAAU/B,WAAW2B,aAAa;aACjC,IAAII,SAAS,CAACN,UAAUQ,GAAG,CAACF,QAA6B;YAC5DN,UAAUS,GAAG,CAACH,OAA4BF,IAAIP,EAAE;QAClD;IACF;IACA,OAAO;QAAEG;QAAWE;QAAWC;IAAK;AACtC;AAEA,MAAMO,cAAc,CAAC1C,OACnBA,KAAKiB,UAAU,CAAC,WAAW0B,MAAM,CAAC,UAAU;AAE9C;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,0BAA4C,OAAO9C,KAAK+C;QAOV/C;IANzD,IAAIA,IAAIgD,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMuC,OACJ,OAAOnD,IAAImD,IAAI,KAAK,WAAWC,KAAKC,KAAK,CAACrD,IAAImD,IAAI,KAAKnD,YAAAA,IAAImD,IAAI,YAARnD,YAAY,CAAC;IACtE,MAAMsD,QAAQrE,kBAAkBkE;IAChC,MAAMlD,iBAASqD,yBAAAA,MAAOrD,MAAM,mBAAI;IAChC,IAAI,CAACqD,SAAS,CAACrD,QAAQ;QACrB8C,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAiB;QAC/C;IACF;IACA,MAAM2C,SAASzE,gBAAgBW,MAAM0D,KAAKK,QAAQ,EAAE;IACpD,IAAI,CAACD,QAAQ;QACXR,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAgB;QAC9C;IACF;IACA,MAAM6C,SAAShE,MAAM0D,KAAKM,MAAM,EAAE;IAElC,IAAI;YA0DEF;QAzDJ,MAAMrC,YAAY3C,cAAcsC,GAAG,GAAGK,SAAS;QAC/C,IAAIwC;QACJ,IAAIJ,MAAMK,KAAK,KAAK,OAAO;YACzB,MAAMC,SAAS,MAAM7E,mBAAmBiB,KAAKsD,MAAM3B,KAAK,EAAE;gBACxDkC,OAAO;gBACPC,SAASvE;YACX;YACA,IAAIqE,OAAOlD,EAAE,KAAK,OAAO;gBACvBqC,IAAIpC,MAAM,CAACiD,OAAOjD,MAAM,EAAEuC,IAAI,CAAC;oBAAEtC,OAAOgD,OAAOhD,KAAK;gBAAC;gBACrD;YACF;YACA,MAAMV,OAAO,MAAM2B,UAAUX,WAAWjB,QAAQ2D,OAAOjC,KAAK;YAC5D,IAAI,CAACzB,MAAM;gBACT6C,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;oBAAEtC,OAAO;gBAAe;gBAC7C;YACF;YACA8C,SAAS;gBACPhD,IAAI;gBACJc,KAAKoC,OAAOpC,GAAG;gBACfE,OAAOkC,OAAOlC,KAAK;gBACnBT,OAAO2C,OAAO3C,KAAK;gBACnBU,OAAOiC,OAAOjC,KAAK;gBACnBC,KAAKgC,OAAOhC,GAAG;gBACf1B;YACF;QACF,OAAO;YACL,MAAM0D,SAAS,MAAM7D,oBAAoBC,KAAKC;YAC9C,IAAI2D,OAAOlD,EAAE,KAAK,OAAO;gBACvBqC,IAAIpC,MAAM,CAACiD,OAAOjD,MAAM,EAAEuC,IAAI,CAAC;oBAAEtC,OAAOgD,OAAOhD,KAAK;gBAAC;gBACrD;YACF;YACA8C,SAASE;QACX;QAEA,oEAAoE;QACpE,qEAAqE;QACrE,iCAAiC;QACjC,MAAMG,QAAQ7E,gBAAgBwE,OAAO9B,GAAG,EAAE;QAC1C,IAAImC,OAAO;YACThB,IAAIpC,MAAM,CAACoD,MAAMpD,MAAM,EAAEuC,IAAI,CAACa,MAAMZ,IAAI;YACxC;QACF;QAEA,qEAAqE;QACrE,oEAAoE;QACpE,2DAA2D;QAC3D,IAAI,CAACnF,iBAAiB0F,OAAO9B,GAAG,EAAE,YAAY;gBAGJzD;YAFxC4E,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;gBACnBtC,OACE,CAAC,iCAAiC,GAAEzC,4BAAAA,yBAAyB,sBAAzBA,4BAAuC,MAAM,CAAC,CAAC,GACnF;YACJ;YACA;QACF;QAEA,MAAM6F,UAAUN,OAAOxD,IAAI,CAAC+D,GAAG;QAC/B,IAAIC;QACJ,IAAIX,EAAAA,gBAAAA,OAAOY,KAAK,qBAAZZ,cAAca,IAAI,MAAK,QAAQ;gBAkBlBC;YAjBf,IAAI,CAACZ,QAAQ;gBACXV,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;oBAAEtC,OAAO;gBAA+B;gBAC7D;YACF;YACA,MAAMyD,WAAW,MAAML,QAAQ7C,UAAU,CAAC,SAASC,GAAG,CAACqC,QAAQpC,GAAG;YAClE,IAAI,CAACgD,SAAS/C,MAAM,EAAE;gBACpByB,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;oBAAEtC,OAAO;gBAAe;gBAC7C;YACF;YACA,mEAAmE;YACnE,4EAA4E;YAC5E,IAAIyD,SAAShD,GAAG,CAAC,iBAAiB,MAAM;gBACtC0B,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;oBAAEtC,OAAO;gBAA6C;gBAC3E;YACF;YACAsD,OAAO;gBACLnC,IAAIsC,SAAStC,EAAE;gBACfuC,MAAM1E,QAAOyE,gBAAAA,SAAShD,GAAG,CAAC,0BAAbgD,gBAA+B,IAAIxE,IAAI,MAAMwE,SAAStC,EAAE;YACvE;QACF;QAEA,MAAMwC,SAAShB,OAAOiB,KAAK,CAACN,OAAO;YAAEO,QAAQP;QAAK,IAAIzD;QACtD,wEAAwE;QACxE,oEAAoE;QACpE,MAAMiE,UAAUpG,0BAA0BiG;QAC1C,IAAIG,SAAS;YACX3B,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;gBAAEtC,OAAO8D;YAAQ;YACtC;QACF;QAEA,MAAMC,WAAW1G;QACjB,MAAM2G,UAAU,MAAM1D,UAAU2D,cAAc,CAAC,OAAOC;YACpD,MAAMC,SAAS/C,eAAe,MAAM8C,GAAGzD,GAAG,CAACuB,YAAYoB;YACvD,MAAMgB,WAAWD,OAAO7C,SAAS,CAACb,GAAG,CAACkC,OAAOxB,EAAE;YAC/C,IAAIiD,UAAU;gBACZ,OAAO;oBACLrE,QAAQ;oBACRC,OAAO;oBACP+D,UAAUK;gBACZ;YACF;YACA,IAAID,OAAO1C,IAAI,IAAItE,sBAAsB;gBACvC,OAAO;oBACL4C,QAAQ;oBACRC,OAAO,CAAC,uCAAuC,EAAE7C,qBAAqB,SAAS,CAAC;gBAClF;YACF;YACA+G,GAAGG,MAAM,CAACjB,QAAQ7C,UAAU,CAAC,WAAWC,GAAG,CAACuD,WAAW,aAClDvG,wBAAwBmG;gBAC3BW,WAAWtG,WAAWuG,eAAe;gBACrCC,WAAWxG,WAAWuG,eAAe;gBACrCE,WAAW3B,OAAOlC,GAAG;;YAEvB,OAAO;QACT;QACA,IAAIoD,SAAS;YACX7B,IAAIpC,MAAM,CAACiE,QAAQjE,MAAM,EAAEuC,IAAI,CAAC;gBAC9BtC,OAAOgE,QAAQhE,KAAK;eAChBgE,QAAQD,QAAQ,GAAG;gBAAEA,UAAUC,QAAQD,QAAQ;YAAC,IAAI,CAAC;YAE3D;QACF;QAEA,MAAMW,QAAQ;YAAE9D,KAAKkC,OAAOlC,GAAG;YAAEE,OAAOgC,OAAOhC,KAAK;QAAC;QACrD,MAAMjD,gBAAgBwB,QAAQqF,OAAO,oBAAoB;YACvDC,MAAM;YACNxD,IAAI4C;YACJL,MAAMC,OAAOD,IAAI;QACnB;QACA,oEAAoE;QACpE,IAAIhB,MAAMK,KAAK,KAAK,OAAO;YACzB,MAAMjF,eACJgF,OAAO/B,KAAK,EACZ2D,OACA,CAAC,eAAe,EAAE/B,OAAOiC,KAAK,CAAC,YAAY,EAAE1D,UAAU4B,OAAOxD,IAAI,GAAG,EACrE;gBAAEqF,MAAM;gBAAQxD,IAAI9B;gBAAQqE,MAAMxC,UAAU4B,OAAOxD,IAAI;YAAE;QAE7D;QACA,MAAMuF,SAAiC;YACrC/E,IAAI;YACJiE;YACAL,MAAMC,OAAOD,IAAI;YACjBd,UAAUD,OAAOxB,EAAE;QACrB;QACAgB,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAACuC;IACvB,EAAE,OAAO7E,OAAO;QACdI,QAAQJ,KAAK,CAAC,+BAA+BX,QAAQW;QACrDmC,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAoC;IACpE;AACF,EAAC;AAED;;;;;;CAMC,GACD,OAAO,MAAM8E,yBAA2C,OAAO1F,KAAK+C;QAOT/C;IANzD,IAAIA,IAAIgD,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMuC,OACJ,OAAOnD,IAAImD,IAAI,KAAK,WAAWC,KAAKC,KAAK,CAACrD,IAAImD,IAAI,KAAKnD,YAAAA,IAAImD,IAAI,YAARnD,YAAY,CAAC;IACtE,MAAMsD,QAAQrE,kBAAkBkE;IAChC,IAAI,CAACG,OAAO;QACVP,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAA0B;QACxD;IACF;IACA,IAAI;QACF,MAAMM,YAAY3C,cAAcsC,GAAG,GAAGK,SAAS;QAC/C,IAAIS,QAAuB;QAC3B,IAAIC;QACJ,IAAI0B,MAAMK,KAAK,KAAK,OAAO;YACzB,MAAMC,SAAS,MAAM7E,mBAAmBiB,KAAKsD,MAAM3B,KAAK,EAAE;gBACxDkC,OAAO;gBACPC,SAAStE;YACX;YACA,IAAIoE,OAAOlD,EAAE,KAAK,OAAO;gBACvBqC,IAAIpC,MAAM,CAACiD,OAAOjD,MAAM,EAAEuC,IAAI,CAAC;oBAAEtC,OAAOgD,OAAOhD,KAAK;gBAAC;gBACrD;YACF;YACAe,QAAQiC,OAAOjC,KAAK;YACpBC,MAAMgC,OAAOhC,GAAG;QAClB,OAAO;YACL,MAAMgC,SAAS,MAAM7D,oBAAoBC,KAAKsD,MAAMrD,MAAM;YAC1D,IAAI2D,OAAOlD,EAAE,KAAK,OAAO;gBACvBqC,IAAIpC,MAAM,CAACiD,OAAOjD,MAAM,EAAEuC,IAAI,CAAC;oBAAEtC,OAAOgD,OAAOhD,KAAK;gBAAC;gBACrD;YACF;YACAgB,MAAMgC,OAAOhC,GAAG;QAClB;QACA,iEAAiE;QACjE,MAAMmC,QAAQ7E,gBAAgB0C,KAAK;QACnC,IAAImC,OAAO;YACThB,IAAIpC,MAAM,CAACoD,MAAMpD,MAAM,EAAEuC,IAAI,CAACa,MAAMZ,IAAI;YACxC;QACF;QACA,MAAMwC,UAAUhE,QAAQ,MAAM3C,WAAWkC,WAAWS,SAAS;YAAC2B,MAAMrD,MAAM;SAAC;QAC3E,MAAM2F,QAAQ,MAAMC,QAAQC,GAAG,CAC7BH,QAAQI,GAAG,CAAC,OAAO9F;YACjB,MAAM8E,SAAS/C,eACb,MAAMY,YAAY1B,UAAUC,UAAU,CAAC,SAASC,GAAG,CAACnB,SAASoB,GAAG;YAElE,OAAO;gBACLpB;gBACAiC,WAAW;uBAAI6C,OAAO7C,SAAS,CAAC8D,IAAI;iBAAG;gBACvC5D,WAAW2C,OAAO3C,SAAS;YAC7B;QACF;QAEFW,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAExC,IAAI;YAAMkF;QAAM;IACzC,EAAE,OAAOhF,OAAO;QACdI,QAAQJ,KAAK,CAAC,8BAA8BA;QAC5CmC,IAAIpC,MAAM,CAAC,KAAKuC,IAAI,CAAC;YAAEtC,OAAO;QAAsC;IACtE;AACF,EAAC"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/recipe-routes.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\n/**\n * THE RECIPES, INSTALLED FROM THE ORGANIZATION (AGL-2639).\n *\n * On a site, a recipe opens the Actions editor prefilled and writes nothing\n * until the person saves — the editor is the writer, and the site's own\n * role is the authority. The organization-level CRM hub has no editor: it\n * mounts over every site with no site of its own, and an org running five\n * sites installed \"Welcome a new lead\" five times by hand. These two routes\n * are the org hub's door to a site's automations.\n *\n * `crm/recipe-install` writes the action a recipe builds into ONE site's\n * `hosts/{hostId}/actions`, as the site editor would have saved it — the\n * same builder, the same validator, the same stored shape — and refuses a\n * second install of the same recipe on the same site by the stamp the\n * builder puts on it. A server route rather than a client write for three\n * reasons the browser cannot supply: the document is created by a server\n * on every plan (the rules close `actions` to client creates, and the\n * per-site cap is counted here); the stamp is the server's word, not the\n * caller's; and the site is checked to be one the caller may manage —\n * at the org level, a site OF THE ORG the caller holds org-wide reach in,\n * so a host id typed into the body cannot reach a site under another\n * workspace.\n *\n * `crm/recipe-status` reads the stamps back for every site of the org, so\n * the card can say which sites carry which recipe. An action with no stamp\n * at all — saved before the stamp existed — is counted, not ignored: it\n * may well be a recipe, and the reader is told the site \"may already have\n * it\" rather than shown a clean slate.\n *\n * Both routes serve the site variant too (`hostId` alone, authorized by the\n * host role that creates actions today), so a site-level caller gets the\n * same answers; the org variant (`orgId` in the body) is the one the org\n * hub uses.\n */\n\nimport {\n ACTIONS_MAX_PER_HOST,\n type AglynOrganization,\n checkEntitlement,\n createResourceUid,\n hostRoleCanWrite,\n planLabelGrantingFeature,\n type PluginApiHandler,\n type PluginApiRequest,\n siteInteractionDocument,\n} from '@aglyn/aglyn/server'\nimport { interactionRecipeStamp } from '@aglyn/aglyn/plugin-manager/interaction-recipes'\nimport { validateStoredInteraction } from '@aglyn/aglyn/plugin-manager/interaction-step-checks'\nimport {\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n logOrgActivity,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_API_ROUTES,\n type CrmRecipeInstallRequest,\n type CrmRecipeInstallResult,\n type CrmRecipeSiteStatus,\n} from '../constants/api-routes'\nimport { crmActionRecipe, type CrmActionRecipeId } from '../model/crm-recipes'\nimport { authorizeOrgCaller, orgHostIds, readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\nexport const CRM_RECIPE_INSTALL_ROUTE = CRM_API_ROUTES.recipeInstall\nexport const CRM_RECIPE_STATUS_ROUTE = CRM_API_ROUTES.recipeStatus\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/** A verified caller who may write a site's actions, and the site's org. */\ninterface SiteWriter {\n ok: true\n uid: string\n email: string | null\n staff: boolean\n orgId: string\n org: Partial<AglynOrganization>\n host: FirebaseFirestore.DocumentSnapshot\n}\n\nconst INSTALL_REFUSAL =\n 'Installing a recipe requires the data permission across the whole workspace'\nconst STATUS_REFUSAL =\n 'Reading which sites carry a recipe requires the data permission across the whole workspace'\n\nfunction typed(value: unknown, max: number): string {\n return String(value ?? '')\n .trim()\n .slice(0, max)\n}\n\n/**\n * The site variant's authority: the role on the host document that creates\n * an action through `/api/hosts/resources` today. Staff pass, as they pass\n * that route. Answers the host snapshot it read, so the caller reads it\n * once.\n */\nasync function authorizeSiteWriter(\n req: PluginApiRequest,\n hostId: string,\n): Promise<SiteWriter | 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 { ok: false, status: 401, error: 'Unauthenticated' }\n let decoded: { uid: string; email?: 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 { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the recipe site writer could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const staff = decoded.staff === true\n const host = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!host.exists) return { ok: false, status: 404, error: 'Unknown site' }\n const role = (host.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return { ok: false, status: 403, error: 'Editing requires the editor role' }\n }\n const resolved = await getOrgForHost(hostId)\n if (!resolved) return { ok: false, status: 404, error: 'Unknown site' }\n return {\n ok: true,\n uid: decoded.uid,\n email: decoded.email ?? null,\n staff,\n orgId: resolved.orgId,\n org: resolved.org,\n host,\n }\n}\n\n/**\n * The org variant's site check: the host document, when it belongs to the\n * organization the caller was admitted to. A site under another workspace\n * — or none — reads as unknown, so a probe learns nothing from the answer.\n */\nasync function siteOfOrg(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n orgId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const host = await firestore.collection('hosts').doc(hostId).get()\n return host.exists && host.get('orgId') === orgId ? host : null\n}\n\n/** How a site reads in a feed line: its display name, its subdomain, its id. */\nfunction siteLabel(host: FirebaseFirestore.DocumentSnapshot): string {\n return (\n String(host.get('displayName') ?? '').trim() ||\n String(host.get('subdomain') ?? '').trim() ||\n host.id\n )\n}\n\n/**\n * The stamps a site's LIVE actions carry, read with a field mask: the\n * install's duplicate check and the status sweep ask the same question,\n * and neither needs a trigger or a step list to answer it. A soft-deleted\n * action (`deletedAt`) is a freed slot and counts for nothing.\n */\nfunction readSiteStamps(rows: FirebaseFirestore.QuerySnapshot): {\n installed: Map<CrmActionRecipeId, string>\n unstamped: number\n live: number\n} {\n const installed = new Map<CrmActionRecipeId, string>()\n let unstamped = 0\n let live = 0\n for (const row of rows.docs) {\n if (row.get('deletedAt') != null) continue\n live += 1\n const stamp = interactionRecipeStamp(row.data())\n if (stamp === undefined) unstamped += 1\n else if (stamp && !installed.has(stamp as CrmActionRecipeId)) {\n installed.set(stamp as CrmActionRecipeId, row.id)\n }\n }\n return { installed, unstamped, live }\n}\n\nconst stampsQuery = (host: FirebaseFirestore.DocumentReference) =>\n host.collection('actions').select('recipe', 'deletedAt')\n\n/**\n * `POST /api/crm/recipe-install`\n *\n * The body is a CrmRecipeInstallRequest: the site, the recipe, the form a\n * recipe that needs one is keyed on, and the org at the organization level.\n * Answers 201 with a CrmRecipeInstallResult once the action is written, and\n * 409 when a live action on the site already carries the stamp — naming it,\n * so the caller can say \"already installed\" rather than install it twice.\n *\n * The write is one transaction: the stamps are read, the duplicate and the\n * per-site cap are judged, and the document is created — all inside it, so\n * two installs racing for the same site cannot both land.\n */\nexport const crmRecipeInstallHandler: 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: Partial<CrmRecipeInstallRequest> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body as Record<string, unknown>)\n const hostId = scope?.hostId ?? ''\n if (!scope || !hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const recipe = crmActionRecipe(typed(body.recipeId, 64))\n if (!recipe) {\n res.status(400).json({ error: 'Pick a recipe' })\n return\n }\n const formId = typed(body.formId, 200)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n let writer: SiteWriter\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: INSTALL_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n const host = await siteOfOrg(firestore, hostId, caller.orgId)\n if (!host) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n writer = {\n ok: true,\n uid: caller.uid,\n email: caller.email,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org,\n host,\n }\n } else {\n const caller = await authorizeSiteWriter(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n writer = caller\n }\n\n // A recipe is a CRM automation first (AGL-2787): the suite is asked\n // before the actions builder, so a plan without either is told about\n // the one the recipe belongs to.\n const suite = crmSuiteRefusal(writer.org, 'A CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n // The gate the site's own Recipes menu applies, judged here from the\n // org document rather than trusted from the console: a recipe is an\n // action, and the plan that carries one carries the other.\n if (!checkEntitlement(writer.org, 'actions')) {\n res.status(403).json({\n error:\n `The actions builder requires the ${planLabelGrantingFeature('actions') ?? 'Pro'} ` +\n 'plan — see Billing to upgrade',\n })\n return\n }\n\n const hostRef = writer.host.ref\n let form: { id: string; name: string; facts: { routesLeads: boolean } } | undefined\n if (recipe.picks?.kind === 'form') {\n if (!formId) {\n res.status(400).json({ error: 'Pick one of the site’s forms' })\n return\n }\n const snapshot = await hostRef.collection('forms').doc(formId).get()\n if (!snapshot.exists) {\n res.status(404).json({ error: 'Unknown form' })\n return\n }\n // An archived form collects nothing, so a recipe keyed on it would\n // never fire; the name falls back to the id the way every form list's does.\n if (snapshot.get('archivedAt') != null) {\n res.status(400).json({ error: 'That form is archived and collects nothing' })\n return\n }\n form = {\n id: snapshot.id,\n name: String(snapshot.get('displayName') ?? '').trim() || snapshot.id,\n // The fact the Forms plugin's list source shares (AGL-3458): a\n // lead-routed form makes leads, so its recipe listens for one.\n facts: { routesLeads: snapshot.get('routing')?.lead === true },\n }\n }\n\n const action = recipe.build(form ? { picked: form } : undefined)\n // The automation editor's own checks of every step, registered from its\n // declarations: a recipe installs only what that editor would save.\n const problem = validateStoredInteraction(action)\n if (problem) {\n res.status(400).json({ error: problem })\n return\n }\n\n const actionId = createResourceUid()\n const outcome = await firestore.runTransaction(async (tx) => {\n const stamps = readSiteStamps(await tx.get(stampsQuery(hostRef)))\n const existing = stamps.installed.get(recipe.id)\n if (existing) {\n return {\n status: 409,\n error: 'Already installed on this site',\n actionId: existing,\n }\n }\n if (stamps.live >= ACTIONS_MAX_PER_HOST) {\n return {\n status: 403,\n error: `interactions and actions are capped at ${ACTIONS_MAX_PER_HOST} per site`,\n }\n }\n tx.create(hostRef.collection('actions').doc(actionId), {\n ...siteInteractionDocument(action),\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n createdBy: writer.uid,\n })\n return null\n })\n if (outcome) {\n res.status(outcome.status).json({\n error: outcome.error,\n ...(outcome.actionId ? { actionId: outcome.actionId } : {}),\n })\n return\n }\n\n const actor = { uid: writer.uid, email: writer.email }\n await logHostActivity(hostId, actor, 'Installed recipe', {\n type: 'content',\n id: actionId,\n name: action.name,\n })\n // The org hub's own feed line, for the act it performed (AGL-2634).\n if (scope.level === 'org') {\n await logOrgActivity(\n writer.orgId,\n actor,\n `Installed the “${recipe.title}” recipe on ${siteLabel(writer.host)}`,\n { type: 'host', id: hostId, name: siteLabel(writer.host) },\n )\n }\n const answer: CrmRecipeInstallResult = {\n ok: true,\n actionId,\n name: action.name,\n recipeId: recipe.id,\n }\n res.status(201).json(answer)\n } catch (error) {\n console.error('[crm] recipe install failed', hostId, error)\n res.status(500).json({ error: 'The recipe could not be installed' })\n }\n}\n\n/**\n * `POST /api/crm/recipe-status`\n *\n * The body names the organization (`orgId`) for every one of its sites, or\n * one site (`hostId`) for that site alone. Answers `ok: true` and `sites`,\n * one CrmRecipeSiteStatus per site swept.\n */\nexport const crmRecipeStatusHandler: 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: Record<string, unknown> =\n typeof req.body === 'string' ? JSON.parse(req.body) : (req.body ?? {})\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing orgId or hostId' })\n return\n }\n try {\n const firestore = firebaseAdmin.app().firestore()\n let orgId: string | null = null\n let org: unknown\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: STATUS_REFUSAL,\n })\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n orgId = caller.orgId\n org = caller.org\n } else {\n const caller = await authorizeSiteWriter(req, scope.hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n org = caller.org\n }\n // The plan after the caller, before any site is read (AGL-2851).\n const suite = crmSuiteRefusal(org, 'Reading which sites carry a CRM recipe')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const hostIds = orgId ? await orgHostIds(firestore, orgId) : [scope.hostId]\n const sites = await Promise.all(\n hostIds.map(async (hostId): Promise<CrmRecipeSiteStatus> => {\n const stamps = readSiteStamps(\n await stampsQuery(firestore.collection('hosts').doc(hostId)).get(),\n )\n return {\n hostId,\n installed: [...stamps.installed.keys()],\n unstamped: stamps.unstamped,\n }\n }),\n )\n res.status(200).json({ ok: true, sites })\n } catch (error) {\n console.error('[crm] recipe status failed', error)\n res.status(500).json({ error: 'The recipe status could not be read' })\n }\n}\n"],"names":["ACTIONS_MAX_PER_HOST","checkEntitlement","createResourceUid","hostRoleCanWrite","planLabelGrantingFeature","siteInteractionDocument","interactionRecipeStamp","validateStoredInteraction","firebaseAdmin","getOrgForHost","logHostActivity","logOrgActivity","isRefusedIdToken","FieldValue","CRM_API_ROUTES","crmActionRecipe","authorizeOrgCaller","orgHostIds","readCrmRouteScope","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","recipeInstall","CRM_RECIPE_STATUS_ROUTE","recipeStatus","INSTALL_REFUSAL","STATUS_REFUSAL","typed","value","max","String","trim","slice","authorizeSiteWriter","req","hostId","host","decoded","authorization","headers","idToken","startsWith","length","undefined","ok","status","error","app","auth","verifyIdToken","console","staff","firestore","collection","doc","get","exists","role","uid","resolved","email","orgId","org","siteOfOrg","siteLabel","id","readSiteStamps","rows","installed","Map","unstamped","live","row","docs","stamp","data","has","set","stampsQuery","select","crmRecipeInstallHandler","res","method","setHeader","json","body","JSON","parse","scope","recipe","recipeId","formId","writer","level","caller","needs","refusal","suite","hostRef","ref","form","picks","kind","snapshot","name","facts","routesLeads","lead","action","build","picked","problem","actionId","outcome","runTransaction","tx","stamps","existing","create","createdAt","serverTimestamp","updatedAt","createdBy","actor","type","title","answer","crmRecipeStatusHandler","hostIds","sites","Promise","all","map","keys"],"mappings":"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"}
|
|
@@ -15,14 +15,18 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { registerPluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
|
|
18
|
-
import { checkEntitlement, consentGroupForHost, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, crmActivityLogHasRoom, crmScopeTokens, crmTaskLabelsForNew, crmTaskListFields,
|
|
19
|
-
import { countCrmActivitiesForRecord,
|
|
18
|
+
import { checkEntitlement, consentGroupForHost, CRM_ACTIVITY_LOG_FULL_MESSAGE, CRM_COLLECTIONS, crmActivityLogHasRoom, CRM_EMAIL_ACTIVITY_TAG, CRM_EMAIL_ORG_TAG, crmScopeTokens, crmTaskLabelsForNew, crmTaskListFields, isCrmActivityKind, isCrmTaskKind, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
19
|
+
import { countCrmActivitiesForRecord, findContactByEmail, firebaseAdmin, readLeadForHost } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
|
|
20
21
|
import { createHash } from "crypto";
|
|
21
22
|
import { FieldValue } from "firebase-admin/firestore";
|
|
22
23
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
23
24
|
import { buildCrmCapturedEmailActivity, crmCapturedEmailKey } from "../model/crm-inbound.js";
|
|
24
25
|
import { readCrmTaskPicklists } from "./read-picklist.js";
|
|
25
26
|
import { CRM_SUITE_FEATURE } from "./suite-gate.js";
|
|
27
|
+
import { crmTaskReminderAfterEdit } from "../model/crm-task-reminders.js";
|
|
28
|
+
import { recomputeCrmNextTaskAt } from "./crm-next-activity.js";
|
|
29
|
+
import { createCrmEmailActivity, crmActivityRef, crmCapturedEmailActivityRef, crmEmailDeliveryStateForEvent, recordCrmEmailDelivery } from "./crm-email-activity.js";
|
|
26
30
|
/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */ const ALREADY_EXISTS = 6;
|
|
27
31
|
/** The longest task title the CRM keeps, as its own forms do. */ const TASK_TITLE_MAX = 200;
|
|
28
32
|
/** The longest note body or task note an entry filed here keeps. */ const NOTE_MAX = 2000;
|
|
@@ -346,6 +350,26 @@ export function createCrmRecordTimelineWriter(deps) {
|
|
|
346
350
|
created: outcome === 'advanced'
|
|
347
351
|
};
|
|
348
352
|
},
|
|
353
|
+
/*
|
|
354
|
+
* A delivery event for a message the CRM tagged (AGL-2615): an email sent
|
|
355
|
+
* from a record, or an automation's email to its contact, carries the org
|
|
356
|
+
* and the activity it was filed as. The state comes from the log's own
|
|
357
|
+
* vocabulary; both ids are path components, so both are checked before
|
|
358
|
+
* either names a document. Tags that name no activity of the CRM's are
|
|
359
|
+
* somebody else's message. The write is forward-only and never throws.
|
|
360
|
+
*/ async recordTaggedDelivery (request) {
|
|
361
|
+
var _request_tags, _request_tags1;
|
|
362
|
+
const state = crmEmailDeliveryStateForEvent(request.event);
|
|
363
|
+
const activityId = (_request_tags = request.tags) == null ? void 0 : _request_tags[CRM_EMAIL_ACTIVITY_TAG];
|
|
364
|
+
const orgId = (_request_tags1 = request.tags) == null ? void 0 : _request_tags1[CRM_EMAIL_ORG_TAG];
|
|
365
|
+
if (!state || !isDocumentId(activityId) || !isDocumentId(orgId)) return;
|
|
366
|
+
await recordCrmEmailDelivery(deps.firestore(), {
|
|
367
|
+
orgId,
|
|
368
|
+
activityId,
|
|
369
|
+
state,
|
|
370
|
+
atMs: Number.isFinite(request.atMs) ? request.atMs : Date.now()
|
|
371
|
+
});
|
|
372
|
+
},
|
|
349
373
|
// An automation's email to the person its event is about (AGL-2615):
|
|
350
374
|
// see `automation-steps.ts`, loaded with the first one.
|
|
351
375
|
async prepareEmail (request) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n isCrmActivityKind,\n isCrmTaskKind,\n readContactFacet,\n visibleToHost,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n findContactByEmail,\n firebaseAdmin,\n readLeadForHost,\n recomputeCrmNextTaskAt,\n recordCrmEmailDelivery,\n} from '@aglyn/tenant-data-admin'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { buildCrmCapturedEmailActivity, crmCapturedEmailKey } from '../model/crm-inbound'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n *\n * ## A record found rather than named\n *\n * A caller that holds no id of the CRM's — a booking made through a link a\n * rep dropped from a record, or one taken off the widget cold (AGL-2660) —\n * hands back the record the link carried, or the booker's address, and the\n * entry is filed where the CRM finds it: the record the reference names\n * when this site may see it, otherwise the contact the address names\n * through the org's address index, narrowed to the site. A deal carries its\n * contact and company onto the entry; a contact its company, from the\n * site's own facet. A task with no assignee of the caller's goes to whoever\n * holds that record.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** What a reference handed back by a caller may name: the records a booking link is dropped from. */\nconst FOUND_RECORD_KINDS: ReadonlySet<string> = new Set(['contact', 'lead', 'deal'])\n/**\n * A record id as the CRM mints them: Firestore's for a contact and a deal, a\n * person key for a lead. A reference arrives on a public request, so one\n * that could not be an id is passed over rather than read.\n */\nconst RECORD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/** The record an entry lands on, and who holds it. */\ninterface FoundRecord {\n link: CrmActivityLink\n holderUid?: string\n}\n\n/** A contact's link and holder, from this site's facet on it. */\nfunction contactRecord(\n contact: FirebaseFirestore.DocumentSnapshot,\n groupId: string,\n): FoundRecord {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: { contactId: contact.id, ...(facet.companyId ? { companyId: facet.companyId } : {}) },\n ...(facet.ownerUid ? { holderUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * The record a caller's reference names, then the contact its address does,\n * as `hostId` may see them; `null` when neither finds one. See the module\n * comment.\n */\nasync function findRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: PluginRecordEntryContext['link'],\n): Promise<FoundRecord | null> {\n const contacts = orgRef.collection('contacts')\n const kind = String(link?.record?.kind ?? '')\n const id = String(link?.record?.id ?? '')\n if (FOUND_RECORD_KINDS.has(kind) && RECORD_ID_PATTERN.test(id)) {\n if (kind === 'contact') {\n const contact = await contacts.doc(id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n return contactRecord(contact, groupId)\n }\n } else if (kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: { dealId: deal.id, ...(contactId ? { contactId } : {}), ...(companyId ? { companyId } : {}) },\n ...(ownerUid ? { holderUid: ownerUid } : {}),\n }\n }\n } else {\n // A lead carries its own `visibleTo` (AGL-3275), and the site has to be\n // allowed to see it, or one brand's booking would name another's lead.\n const lead = await readLeadForHost(hostId, id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return { link: { leadId: lead.id }, ...(ownerUid ? { holderUid: ownerUid } : {}) }\n }\n }\n }\n const email = String(link?.email ?? '').trim()\n if (!email) return null\n const hit = await findContactByEmail(contacts, email, { hostId })\n return hit ? contactRecord(hit, groupId) : null\n}\n\n/** Who holds a record a caller named by id: its contact's site facet, or its lead. */\nasync function holderOf(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: CrmActivityLink,\n): Promise<string | undefined> {\n if (link.contactId) {\n const contact = await orgRef.collection('contacts').doc(link.contactId).get()\n return contact.exists ? contactRecord(contact, groupId).holderUid : undefined\n }\n if (link.leadId) {\n const lead = await readLeadForHost(hostId, link.leadId)\n return String(lead?.get('ownerUid') ?? '') || undefined\n }\n return undefined\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /**\n * The org, its plan, the link and the scope an entry is stamped with — and,\n * when `wantHolder`, who holds the record it lands on.\n */\n async function admit(\n context: PluginRecordEntryContext,\n wantHolder = false,\n ): Promise<\n | PluginRecordWrite\n | {\n firestore: Firestore\n orgRef: FirebaseFirestore.DocumentReference\n link: CrmActivityLink\n visibleTo: string[]\n holderUid?: string\n }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const named = linkFields(context.link)\n const isNamed = Boolean(named.contactId || named.companyId || named.leadId)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!isNamed && !context.link?.record && !String(context.link?.email ?? '').trim()) {\n return refuse(400, 'An entry is filed on a contact, a company or a lead, or names how to find one.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n const group = consentGroupForHost(org, hostId)\n let link = named\n let holderUid: string | undefined\n if (isNamed) {\n if (wantHolder) holderUid = await holderOf(orgRef, hostId, group.groupId, named)\n } else {\n const found = await findRecord(orgRef, hostId, group.groupId, context.link)\n if (!found) return refuse(404, 'Nothing this site keeps matches the record or the address.')\n link = found.link\n holderUid = found.holderUid\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, group),\n ...(holderUid ? { holderUid } : {}),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request, request.assigneeUid === null)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n // `null` is \"whoever holds the record\", which admission read.\n const assigneeUid =\n request.assigneeUid === null\n ? (admitted.holderUid ?? '')\n : String(request.assigneeUid ?? '').trim()\n // The org's labels for the meanings it is filed with (AGL-3517).\n const labels = crmTaskLabelsForNew(await readCrmTaskPicklists(firestore, orgRef.id), {\n kind: request.kind,\n priority: 'normal',\n })\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n ...labels,\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const created = await createOnce(ref, { ...task, ...crmTaskListFields(task) })\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n\n // An automation's email to the person its event is about (AGL-2615):\n // see `automation-steps.ts`, loaded with the first one.\n async prepareEmail(request) {\n const { prepareCrmRecordEmail } = await import('./automation-steps')\n return prepareCrmRecordEmail(request)\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","crmTaskReminderAfterEdit","isCrmActivityKind","isCrmTaskKind","readContactFacet","visibleToHost","countCrmActivitiesForRecord","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","findContactByEmail","firebaseAdmin","readLeadForHost","recomputeCrmNextTaskAt","recordCrmEmailDelivery","createHash","FieldValue","BUNDLE_ID","buildCrmCapturedEmailActivity","crmCapturedEmailKey","readCrmTaskPicklists","CRM_SUITE_FEATURE","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","leadId","FOUND_RECORD_KINDS","Set","RECORD_ID_PATTERN","contactRecord","contact","groupId","facet","data","id","ownerUid","holderUid","findRecord","orgRef","hostId","contacts","collection","kind","record","has","test","doc","get","exists","deal","deals","dealId","lead","email","trim","hit","holderOf","undefined","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","wantHolder","snapshot","orgId","named","isNamed","Boolean","firestore","org","group","found","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","admitted","activity","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","assigneeUid","tasks","notes","labels","priority","task","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","prepareEmail","prepareCrmRecordEmail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/record-timeline.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 registerPluginRecordTimelineWriter,\n type PluginRecordActivityRequest,\n type PluginRecordDeliveryRequest,\n type PluginRecordEntryContext,\n type PluginRecordTaggedDeliveryRequest,\n type PluginRecordTaskRequest,\n type PluginRecordTimelineWriter,\n type PluginRecordWrite,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n CRM_EMAIL_ACTIVITY_TAG,\n CRM_EMAIL_ORG_TAG,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n isCrmActivityKind,\n isCrmTaskKind,\n readContactFacet,\n visibleToHost,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n} from '@aglyn/aglyn/server'\nimport {\n countCrmActivitiesForRecord,\n findContactByEmail,\n firebaseAdmin,\n readLeadForHost,\n} from '@aglyn/tenant-data-admin'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport { createHash } from 'crypto'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { buildCrmCapturedEmailActivity, crmCapturedEmailKey } from '../model/crm-inbound'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\nimport { crmTaskReminderAfterEdit } from '../model/crm-task-reminders'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport {\n createCrmEmailActivity,\n crmActivityRef,\n crmCapturedEmailActivityRef,\n crmEmailDeliveryStateForEvent,\n recordCrmEmailDelivery,\n} from './crm-email-activity'\n\n/**\n * THE CRM'S WRITER ON THE CORE'S RECORD-TIMELINE SEAM (AGL-2981).\n *\n * Another plugin files an activity or a task on a contact, company or deal —\n * an email a sequence sent, the reply it read, the call it asks for — and\n * this writes it as the CRM writes its own:\n *\n * 1. the workspace's plan carries the CRM (`features.crm`), as at every\n * CRM door; a Free workspace keeps no records to file on;\n * 2. the entry carries the scope a record made on that site carries\n * (`crmScopeTokens`), so exactly the people who see the record see it;\n * 3. an EMAIL is filed under the id the capture address files a copy of the\n * same message by (`cap_` + the digest of its `Message-ID`), and written\n * with `create()`: the send, a copy the rep forwarded to the capture\n * address, and a second run of the caller are one row;\n * 4. anything else — a note, a task — is filed under the caller's own key,\n * hashed with the caller's plugin id, and written once the same way;\n * 5. a record at its activity ceiling is refused with the sentence every\n * CRM writer answers with, before anything is written;\n * 6. a bounce or a complaint on a filed email (AGL-3245) advances that\n * email's own delivery state — the row the send was filed under, found\n * by its `Message-ID` — exactly as the campaign webhook advances one,\n * so the timeline reads Sent, then Bounced, with what the server said.\n *\n * Each entry names the plugin that filed it (`sourcePluginId`), and a task's\n * `nextTaskAtMs` is recomputed on the records it names, as every server\n * writer of a task does.\n *\n * ## A record found rather than named\n *\n * A caller that holds no id of the CRM's — a booking made through a link a\n * rep dropped from a record, or one taken off the widget cold (AGL-2660) —\n * hands back the record the link carried, or the booker's address, and the\n * entry is filed where the CRM finds it: the record the reference names\n * when this site may see it, otherwise the contact the address names\n * through the org's address index, narrowed to the site. A deal carries its\n * contact and company onto the entry; a contact its company, from the\n * site's own facet. A task with no assignee of the caller's goes to whoever\n * holds that record.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\nexport interface CrmRecordTimelineDeps {\n firestore(): Firestore\n}\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\n/** The longest task title the CRM keeps, as its own forms do. */\nconst TASK_TITLE_MAX = 200\n/** The longest note body or task note an entry filed here keeps. */\nconst NOTE_MAX = 2_000\n\nconst refuse = (status: 400 | 403 | 404 | 409, error: string): PluginRecordWrite => ({\n ok: false,\n status,\n error,\n})\n\n/** The record fields of a link, only the ones it names. */\nfunction linkFields(link: PluginRecordEntryContext['link']): CrmActivityLink {\n return {\n ...(link?.contactId ? { contactId: String(link.contactId) } : {}),\n ...(link?.companyId ? { companyId: String(link.companyId) } : {}),\n // A lead (AGL-3234): host-scoped by path, so the entry carries the\n // site's scope like any record created from that site.\n ...(link?.leadId ? { leadId: String(link.leadId) } : {}),\n }\n}\n\n/** What a reference handed back by a caller may name: the records a booking link is dropped from. */\nconst FOUND_RECORD_KINDS: ReadonlySet<string> = new Set(['contact', 'lead', 'deal'])\n/**\n * A record id as the CRM mints them: Firestore's for a contact and a deal, a\n * person key for a lead. A reference arrives on a public request, so one\n * that could not be an id is passed over rather than read.\n */\nconst RECORD_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/\n\n/** The record an entry lands on, and who holds it. */\ninterface FoundRecord {\n link: CrmActivityLink\n holderUid?: string\n}\n\n/** A contact's link and holder, from this site's facet on it. */\nfunction contactRecord(\n contact: FirebaseFirestore.DocumentSnapshot,\n groupId: string,\n): FoundRecord {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: { contactId: contact.id, ...(facet.companyId ? { companyId: facet.companyId } : {}) },\n ...(facet.ownerUid ? { holderUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * The record a caller's reference names, then the contact its address does,\n * as `hostId` may see them; `null` when neither finds one. See the module\n * comment.\n */\nasync function findRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: PluginRecordEntryContext['link'],\n): Promise<FoundRecord | null> {\n const contacts = orgRef.collection('contacts')\n const kind = String(link?.record?.kind ?? '')\n const id = String(link?.record?.id ?? '')\n if (FOUND_RECORD_KINDS.has(kind) && RECORD_ID_PATTERN.test(id)) {\n if (kind === 'contact') {\n const contact = await contacts.doc(id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n return contactRecord(contact, groupId)\n }\n } else if (kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: { dealId: deal.id, ...(contactId ? { contactId } : {}), ...(companyId ? { companyId } : {}) },\n ...(ownerUid ? { holderUid: ownerUid } : {}),\n }\n }\n } else {\n // A lead carries its own `visibleTo` (AGL-3275), and the site has to be\n // allowed to see it, or one brand's booking would name another's lead.\n const lead = await readLeadForHost(hostId, id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return { link: { leadId: lead.id }, ...(ownerUid ? { holderUid: ownerUid } : {}) }\n }\n }\n }\n const email = String(link?.email ?? '').trim()\n if (!email) return null\n const hit = await findContactByEmail(contacts, email, { hostId })\n return hit ? contactRecord(hit, groupId) : null\n}\n\n/** Who holds a record a caller named by id: its contact's site facet, or its lead. */\nasync function holderOf(\n orgRef: FirebaseFirestore.DocumentReference,\n hostId: string,\n groupId: string,\n link: CrmActivityLink,\n): Promise<string | undefined> {\n if (link.contactId) {\n const contact = await orgRef.collection('contacts').doc(link.contactId).get()\n return contact.exists ? contactRecord(contact, groupId).holderUid : undefined\n }\n if (link.leadId) {\n const lead = await readLeadForHost(hostId, link.leadId)\n return String(lead?.get('ownerUid') ?? '') || undefined\n }\n return undefined\n}\n\n/** The document id a caller's key files under: its own namespace, never a raw key. */\nfunction keyedId(sourcePluginId: string, key: string): string {\n return `plg_${createHash('sha256').update(`${sourcePluginId}:${key}`).digest('hex').slice(0, 28)}`\n}\n\nexport function createCrmRecordTimelineWriter(deps: CrmRecordTimelineDeps): PluginRecordTimelineWriter {\n /**\n * The org, its plan, the link and the scope an entry is stamped with — and,\n * when `wantHolder`, who holds the record it lands on.\n */\n async function admit(\n context: PluginRecordEntryContext,\n wantHolder = false,\n ): Promise<\n | PluginRecordWrite\n | {\n firestore: Firestore\n orgRef: FirebaseFirestore.DocumentReference\n link: CrmActivityLink\n visibleTo: string[]\n holderUid?: string\n }\n > {\n const orgId = String(context.orgId ?? '').trim()\n const hostId = String(context.hostId ?? '').trim()\n const named = linkFields(context.link)\n const isNamed = Boolean(named.contactId || named.companyId || named.leadId)\n if (!orgId || !hostId || !String(context.sourcePluginId ?? '').trim()) {\n return refuse(400, 'An entry names its organization, its site and the plugin filing it.')\n }\n if (!isNamed && !context.link?.record && !String(context.link?.email ?? '').trim()) {\n return refuse(400, 'An entry is filed on a contact, a company or a lead, or names how to find one.')\n }\n const firestore = deps.firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const snapshot = await orgRef.get()\n if (!snapshot.exists) return refuse(404, 'That organization no longer exists.')\n const org = (snapshot.data() ?? {}) as Record<string, unknown>\n if (!checkEntitlement(org, CRM_SUITE_FEATURE)) {\n return refuse(403, \"This workspace's plan doesn't include the CRM.\")\n }\n const group = consentGroupForHost(org, hostId)\n let link = named\n let holderUid: string | undefined\n if (isNamed) {\n if (wantHolder) holderUid = await holderOf(orgRef, hostId, group.groupId, named)\n } else {\n const found = await findRecord(orgRef, hostId, group.groupId, context.link)\n if (!found) return refuse(404, 'Nothing this site keeps matches the record or the address.')\n link = found.link\n holderUid = found.holderUid\n }\n return {\n firestore,\n orgRef,\n link,\n visibleTo: crmScopeTokens(org, group),\n ...(holderUid ? { holderUid } : {}),\n }\n }\n\n /** Creates a keyed entry once: `created: false` for one already filed. */\n async function createOnce(\n ref: FirebaseFirestore.DocumentReference,\n data: Record<string, unknown>,\n ): Promise<boolean> {\n try {\n await ref.create({\n ...data,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n if ((error as { code?: unknown })?.code === ALREADY_EXISTS) return false\n throw error\n }\n }\n\n return {\n async logActivity(request: PluginRecordActivityRequest): Promise<PluginRecordWrite> {\n if (!isCrmActivityKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't an activity.`)\n const admitted = await admit(request)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const orgId = orgRef.id\n\n let ref: FirebaseFirestore.DocumentReference\n let activity: CrmActivity\n if (request.kind === 'email') {\n const email = request.email\n const key = email ? crmCapturedEmailKey(email.messageId, null) : null\n if (!email || !key) return refuse(400, 'An email is filed by its Message-ID.')\n ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n activity = buildCrmCapturedEmailActivity({\n direction: email.direction === 'inbound' ? 'inbound' : 'outbound',\n subject: email.subject,\n excerpt: request.body,\n from: email.from,\n to: email.to,\n messageId: email.messageId,\n inReplyTo: email.inReplyTo ?? null,\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n byName: request.byName ?? null,\n link,\n hostId: request.hostId,\n visibleTo,\n })\n } else {\n const key = String(request.dedupeKey ?? '').trim()\n if (!key) return refuse(400, 'An entry that is not an email names its own key.')\n ref = crmActivityRef(firestore, orgId, keyedId(request.sourcePluginId, key))\n const byName = String(request.byName ?? '').trim()\n activity = {\n kind: request.kind,\n body: String(request.body ?? '').slice(0, NOTE_MAX),\n atMs: request.atMs,\n byUid: String(request.byUid ?? ''),\n ...(byName ? { byName } : {}),\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n }\n // Filed before: answered off the one read, ahead of the ceiling's count.\n if ((await ref.get()).exists) return { ok: true, id: ref.id, created: false }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return refuse(409, CRM_ACTIVITY_LOG_FULL_MESSAGE)\n }\n const stamped = { ...activity, sourcePluginId: request.sourcePluginId }\n const created =\n request.kind === 'email'\n ? (await createCrmEmailActivity(ref, stamped)) === 'created'\n : await createOnce(ref, stamped as unknown as Record<string, unknown>)\n return { ok: true, id: ref.id, created }\n },\n\n async createTask(request: PluginRecordTaskRequest): Promise<PluginRecordWrite> {\n const title = String(request.title ?? '').replace(/\\s+/g, ' ').trim().slice(0, TASK_TITLE_MAX)\n const key = String(request.dedupeKey ?? '').trim()\n if (!title || !key) return refuse(400, 'A task has a title and the caller’s own key.')\n if (!isCrmTaskKind(request.kind)) return refuse(400, `\"${String(request.kind)}\" isn't a task.`)\n if (!Number.isFinite(request.dueAtMs)) return refuse(400, 'A task has a due time.')\n const admitted = await admit(request, request.assigneeUid === null)\n if ('ok' in admitted) return admitted\n const { firestore, orgRef, link, visibleTo } = admitted\n const ref = orgRef.collection(CRM_COLLECTIONS.tasks).doc(keyedId(request.sourcePluginId, key))\n const notes = String(request.notes ?? '').trim().slice(0, NOTE_MAX)\n // `null` is \"whoever holds the record\", which admission read.\n const assigneeUid =\n request.assigneeUid === null\n ? (admitted.holderUid ?? '')\n : String(request.assigneeUid ?? '').trim()\n // The org's labels for the meanings it is filed with (AGL-3517).\n const labels = crmTaskLabelsForNew(await readCrmTaskPicklists(firestore, orgRef.id), {\n kind: request.kind,\n priority: 'normal',\n })\n const task: CrmTask = {\n title,\n ...(notes ? { notes } : {}),\n kind: request.kind,\n priority: 'normal',\n status: 'open',\n ...labels,\n dueAtMs: request.dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs: request.dueAtMs, previous: null }),\n ...(assigneeUid ? { assigneeUid } : {}),\n createdByUid: String(request.createdByUid ?? ''),\n sourcePluginId: request.sourcePluginId,\n ...link,\n hostId: request.hostId,\n visibleTo,\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const created = await createOnce(ref, { ...task, ...crmTaskListFields(task) })\n if (created) {\n // The records the task names carry `nextTaskAtMs` (AGL-2661); a figure\n // that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [link]).catch((error: unknown) => {\n console.error('[crm] next activity could not be recomputed', orgRef.id, error)\n })\n }\n return { ok: true, id: ref.id, created }\n },\n\n async recordEmailDelivery(request: PluginRecordDeliveryRequest): Promise<PluginRecordWrite> {\n const orgId = String(request.orgId ?? '').trim()\n const key = crmCapturedEmailKey(request.messageId, null)\n if (!orgId || !key) return refuse(400, 'A delivery names its organization and the email’s Message-ID.')\n if (request.state !== 'bounced' && request.state !== 'complained') {\n return refuse(400, `\"${String(request.state)}\" isn't a delivery failure.`)\n }\n const firestore = deps.firestore()\n const ref = crmCapturedEmailActivityRef(firestore, orgId, key)\n const outcome = await recordCrmEmailDelivery(firestore, {\n orgId,\n activityId: ref.id,\n state: request.state,\n atMs: request.atMs,\n detail: request.detail ?? null,\n })\n if (outcome === 'missing') return refuse(404, 'No email by that Message-ID is filed here.')\n if (outcome === 'failed') return refuse(409, 'The delivery state could not be written.')\n return { ok: true, id: ref.id, created: outcome === 'advanced' }\n },\n\n /*\n * A delivery event for a message the CRM tagged (AGL-2615): an email sent\n * from a record, or an automation's email to its contact, carries the org\n * and the activity it was filed as. The state comes from the log's own\n * vocabulary; both ids are path components, so both are checked before\n * either names a document. Tags that name no activity of the CRM's are\n * somebody else's message. The write is forward-only and never throws.\n */\n async recordTaggedDelivery(request: PluginRecordTaggedDeliveryRequest): Promise<void> {\n const state = crmEmailDeliveryStateForEvent(request.event)\n const activityId = request.tags?.[CRM_EMAIL_ACTIVITY_TAG]\n const orgId = request.tags?.[CRM_EMAIL_ORG_TAG]\n if (!state || !isDocumentId(activityId) || !isDocumentId(orgId)) return\n await recordCrmEmailDelivery(deps.firestore(), {\n orgId,\n activityId,\n state,\n atMs: Number.isFinite(request.atMs) ? request.atMs : Date.now(),\n })\n },\n\n // An automation's email to the person its event is about (AGL-2615):\n // see `automation-steps.ts`, loaded with the first one.\n async prepareEmail(request) {\n const { prepareCrmRecordEmail } = await import('./automation-steps')\n return prepareCrmRecordEmail(request)\n },\n }\n}\n\n/** The platform's own dependencies. Specs build their own. */\nexport function defaultCrmRecordTimelineDeps(): CrmRecordTimelineDeps {\n return { firestore: () => firebaseAdmin.app().firestore() }\n}\n\n/** Registers the CRM as the workspace's record system on the timeline seam. */\nexport function registerCrmRecordTimelineWriter(\n deps: CrmRecordTimelineDeps = defaultCrmRecordTimelineDeps(),\n): void {\n registerPluginRecordTimelineWriter(createCrmRecordTimelineWriter(deps), { pluginId: BUNDLE_ID })\n}\n"],"names":["registerPluginRecordTimelineWriter","checkEntitlement","consentGroupForHost","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","CRM_EMAIL_ACTIVITY_TAG","CRM_EMAIL_ORG_TAG","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","isCrmActivityKind","isCrmTaskKind","readContactFacet","visibleToHost","countCrmActivitiesForRecord","findContactByEmail","firebaseAdmin","readLeadForHost","isDocumentId","createHash","FieldValue","BUNDLE_ID","buildCrmCapturedEmailActivity","crmCapturedEmailKey","readCrmTaskPicklists","CRM_SUITE_FEATURE","crmTaskReminderAfterEdit","recomputeCrmNextTaskAt","createCrmEmailActivity","crmActivityRef","crmCapturedEmailActivityRef","crmEmailDeliveryStateForEvent","recordCrmEmailDelivery","ALREADY_EXISTS","TASK_TITLE_MAX","NOTE_MAX","refuse","status","error","ok","linkFields","link","contactId","String","companyId","leadId","FOUND_RECORD_KINDS","Set","RECORD_ID_PATTERN","contactRecord","contact","groupId","facet","data","id","ownerUid","holderUid","findRecord","orgRef","hostId","contacts","collection","kind","record","has","test","doc","get","exists","deal","deals","dealId","lead","email","trim","hit","holderOf","undefined","keyedId","sourcePluginId","key","update","digest","slice","createCrmRecordTimelineWriter","deps","admit","context","wantHolder","snapshot","orgId","named","isNamed","Boolean","firestore","org","group","found","visibleTo","createOnce","ref","create","createdAt","serverTimestamp","updatedAt","code","logActivity","request","admitted","activity","messageId","direction","subject","excerpt","body","from","to","inReplyTo","atMs","byUid","byName","dedupeKey","created","stamped","createTask","title","replace","Number","isFinite","dueAtMs","assigneeUid","tasks","notes","labels","priority","task","remindAtMs","previous","createdByUid","catch","console","recordEmailDelivery","state","outcome","activityId","detail","recordTaggedDelivery","event","tags","Date","now","prepareEmail","prepareCrmRecordEmail","defaultCrmRecordTimelineDeps","app","registerCrmRecordTimelineWriter","pluginId"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { buildRoute,
|
|
17
|
+
*/ import { buildRoute, CRM_COLLECTIONS, isReleaseFlagOnForOrg, notificationMuted, parseOrgReleaseFlagOverrides, pluginRequestFromWeb, resolveBrandingProfile, resolveEffectivePlan, resolveOrgEntitlements, Route } from "@aglyn/aglyn/server";
|
|
18
18
|
import { crmDigestEntitled } from "../model/crm-digest.js";
|
|
19
19
|
import { rateLimitedRetryAtMs, sendEmail } from "@aglyn/shared-util-email";
|
|
20
20
|
import { withoutMailWithheldAccounts } from "@aglyn/tenant-data-admin/server/account-mail";
|
|
@@ -30,6 +30,7 @@ import { writeCronBeat } from "@aglyn/aglyn/app-utils/health-report";
|
|
|
30
30
|
import { brandSupportLine } from "@aglyn/aglyn/app-utils/brand-support-line";
|
|
31
31
|
import { platformConsoleOrigin as consoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
32
32
|
import { digestTimeZone } from "./daily-digest-route.js";
|
|
33
|
+
import { composeCrmTaskReminderBody, composeCrmTaskReminderEmailText, composeCrmTaskReminderSubject, CRM_TASK_REMINDER_CEILING, crmTaskReminderDue, crmTaskReminderLink } from "../model/crm-task-reminders.js";
|
|
33
34
|
/**
|
|
34
35
|
* The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the
|
|
35
36
|
* INVOCATION and before the work — the console's rule for every cron. Never
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-reminders-route.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 buildRoute,\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailText,\n composeCrmTaskReminderSubject,\n CRM_COLLECTIONS,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n crmDigestEntitled,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as the digest beside this route.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { digestTimeZone } from './daily-digest-route'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.\n *\n * The digest beside this route says once a morning what is late and what\n * is due today; nothing said \"the 3:00 call is at 3:00\". Every hour this\n * reads, per org, the open tasks whose `remindAtMs` has come and tells\n * each one's assignee ONCE — a console notification per task\n * (`content.taskReminder`, opening the record the task is for) and one\n * email per member per run listing everything of theirs that fell due.\n * The words and the rule live in `crm-task-reminders.ts`; this file is the\n * reads, the sends and the sweep, in the digest's shape wherever the two\n * do the same thing.\n *\n * ## Who is told, and who is not\n *\n * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate\n * choice in the drawer, as the digest reads it. Only a member who may open\n * the CRM (`data.manage`, resolved through their custom role), only in an\n * org whose plan carries the suite and whose `release_crm` flag is\n * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan\n * that gets no digest gets no reminders either. The recipient's\n * operational-category mute (`notificationPrefs.content`) governs the WHOLE\n * reminder, mail included — unlike the digest, which has a switch of its\n * own for the schedule it keeps; a reminder is one message about one task.\n *\n * ## Once, and resumable\n *\n * `reminderSentAtMs` on the task is the idempotence: stamped in one batch\n * with the member's sends, so a rerun over the same hour reads the mark and\n * sends nothing, and stamped on every reminder the run HANDLED — sent, or\n * found nobody to send it to — so a reminder nobody can be told about does\n * not come back every hour. A reminder is left unstamped only when the run\n * never reached a verdict: the send-rate governor refused, or the org\n * threw. Orgs are swept in id order behind a cursor, as the digest sweeps\n * them, so the workflow's `done:false` loop carries a platform of any\n * size across invocations.\n *\n * The task query reads the NEWEST reminders that have come due, not the\n * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a\n * bounded page safe against a window that only ever grows.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron\n * fan-out on the shared sending domain, and the platform reminding a\n * member is not the org's sending. A refusal STOPS the run and reports\n * `done: true`; the refused member was never stamped, and the next hour's\n * run finds their reminders at the top of the window again.\n */\n\n/** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */\nexport const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders'\n\n/**\n * Orgs per invocation.\n *\n * More than the digest's twenty-five: an org here costs one task query and,\n * only when something fell due, the roster and the sends. The common hour\n * finds nothing due in most orgs, and a chunk that reads a hundred empty\n * queries finishes well inside `maxDuration = 60`.\n */\nexport const CRM_TASK_REMINDERS_ORG_CHUNK = 100\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none — the digest's bound, for the\n * digest's reason: work, not audience.\n */\nexport const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n tasks: number\n skipped?: string\n notified?: number\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n /** Reminders that had come due and were not yet handled. */\n due: number\n /** Reminders this run stamped as handled. */\n handled: number\n /** Console notifications sent. */\n notified: number\n /** Emails sent — one per member per run. */\n emailed: number\n members?: Record<string, MemberReport>\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/** A due reminder with the document it came from, so the stamp has a target. */\ninterface DueTask extends CrmReminderTask {\n ref: FirebaseFirestore.DocumentReference\n}\n\nasync function remindOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { due: 0, handled: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n /*\n * Every open task whose reminder has come, newest first, on\n * `(status, remindAtMs desc)`. The \"not yet handled\" half is a fact about\n * a field's absence, which a query cannot select on, so it is asked of\n * the page in memory — and the page being the NEWEST five hundred is what\n * keeps the handled ones from crowding out the next one due.\n */\n const page = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .limit(CRM_TASK_REMINDER_CEILING)\n .get()\n const due: DueTask[] = []\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!crmTaskReminderDue(data as Parameters<typeof crmTaskReminderDue>[0], nowMs)) continue\n due.push({\n ref: doc.ref,\n id: doc.id,\n title: String(data['title'] ?? ''),\n kind: (data['kind'] as CrmReminderTask['kind']) ?? 'todo',\n dueAtMs: typeof data['dueAtMs'] === 'number' ? (data['dueAtMs'] as number) : null,\n remindAtMs: Number(data['remindAtMs']),\n assigneeUid: String(data['assigneeUid'] ?? ''),\n hostId: String(data['hostId'] ?? ''),\n contactId: (data['contactId'] as string) || undefined,\n companyId: (data['companyId'] as string) || undefined,\n dealId: (data['dealId'] as string) || undefined,\n })\n }\n const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING\n if (!due.length) {\n return { ...quiet, skipped: 'nothing-due', ...(tasksTruncated ? { tasksTruncated } : {}) }\n }\n\n const report: OrgReport = { due: due.length, handled: 0, notified: 0, emailed: 0, members: {} }\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n\n /**\n * Stamp a set of reminders as handled, in one batch, unless this is a\n * plan. Everything a member's verdict covers lands together, which is\n * what makes a rerun read the same answer for all of them.\n */\n const settle = async (tasks: DueTask[]): Promise<void> => {\n report.handled += tasks.length\n if (dryRun) return\n const batch = firestore.batch()\n for (const task of tasks) batch.update(task.ref, { reminderSentAtMs: nowMs })\n await batch.commit()\n }\n\n const byAssignee = new Map<string, DueTask[]>()\n const unassigned: DueTask[] = []\n for (const task of due) {\n if (!task.assigneeUid) {\n unassigned.push(task)\n continue\n }\n const mine = byAssignee.get(task.assigneeUid) ?? []\n mine.push(task)\n byAssignee.set(task.assigneeUid, mine)\n }\n // A reminder for nobody is handled by saying so: it would otherwise sit\n // at the top of the window every hour for as long as the task is open.\n if (unassigned.length) {\n rows[''] = { tasks: unassigned.length, skipped: 'unassigned' }\n await settle(unassigned)\n }\n if (!byAssignee.size) return report\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...byAssignee.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n // The sites the due tasks name, for the absolute links the mail carries.\n const hostIds = [...new Set(due.map((task) => task.hostId).filter(Boolean))]\n const hostDocs = hostIds.length\n ? await firestore.getAll(...hostIds.map((hostId) => firestore.collection('hosts').doc(hostId)))\n : []\n const hostById = new Map(hostDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm` : null\n const hubUrl = (hostId: string): string | null => {\n if (!hostId) return orgHub\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The record the task is for, on its own hub — the notification's link\n // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.\n const taskUrl = (task: CrmReminderTask): string => {\n const hub = hubUrl(task.hostId)\n if (!hub) return ctx.origin\n const link = crmTaskReminderLink(task.hostId || null, task)\n return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`\n }\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let reminderEmail: Promise<LoadedSystemEmail | null> | undefined\n let poolLookups = 0\n\n for (const uid of uids) {\n const tasks = byAssignee.get(uid) as DueTask[]\n const row: MemberReport = { tasks: tasks.length }\n rows[uid] = row\n const member = memberById.get(uid)\n if (!member) {\n row.skipped = 'not-a-member'\n await settle(tasks)\n continue\n }\n if (!(await memberHasOrgPermission(orgId, member as AglynOrgMember, 'data.manage'))) {\n row.skipped = 'no-data-manage'\n await settle(tasks)\n continue\n }\n const userDoc = userById.get(uid)\n if (notificationMuted(userDoc?.get('notificationPrefs'), 'content.taskReminder')) {\n row.skipped = 'muted'\n await settle(tasks)\n continue\n }\n if (dryRun) {\n row.notified = tasks.length\n row.emailed = true\n await settle(tasks)\n continue\n }\n\n /*\n * THE EMAIL FIRST, for the digest's reason: a refusal from the\n * send-rate governor stops the run with nothing else done for this\n * member — no notification to double next hour, no mark to skip them\n * on — so the order here is the idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_TASK_REMINDERS_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const reminderInput = {\n tasks,\n timeZone,\n productName: branding.productName,\n workspaceName,\n taskUrl,\n settingsUrl,\n supportLine,\n }\n const subject = composeCrmTaskReminderSubject(tasks)\n // The `crm-task-reminder` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n reminderEmail ??= loadSystemEmail('crm-task-reminder').catch(() => null)\n const loaded = await reminderEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'reminder.subject': subject,\n 'reminder.body': composeCrmTaskReminderEmailText({ ...reminderInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmTaskReminderEmailText(reminderInput),\n }),\n fromName: branding.fromName,\n context: 'crm-task-reminder',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n // One notification per task: each opens the record it is for.\n for (const task of tasks) {\n await notifyUsers([uid], {\n type: 'content.taskReminder',\n title: 'Task reminder',\n body: composeCrmTaskReminderBody(task, timeZone, workspaceName),\n link: crmTaskReminderLink(task.hostId || null, task),\n orgId,\n ...(task.hostId ? { hostId: task.hostId } : {}),\n })\n }\n await settle(tasks)\n row.notified = tasks.length\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n report.notified += tasks.length\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmTaskRemindersRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'Task reminders are not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955): an hour with\n // nothing due still ran.\n if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape the digest reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK)\n : CRM_TASK_REMINDERS_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let due = 0\n let handled = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await remindOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n due += report.due\n handled += report.handled\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] task reminders failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n due,\n handled,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] task reminders run failed', error)\n return Response.json({ error: 'Task reminders run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","composeCrmTaskReminderBody","composeCrmTaskReminderEmailText","composeCrmTaskReminderSubject","CRM_COLLECTIONS","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","crmDigestEntitled","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","digestTimeZone","recordCronBeat","jobId","app","firestore","CRM_TASK_REMINDERS_JOB_ID","CRM_TASK_REMINDERS_ORG_CHUNK","CRM_TASK_REMINDERS_MAX_UID_LOOKUPS","remindOrg","ctx","orgDoc","org","nowMs","timeZone","dryRun","orgId","id","data","quiet","due","handled","notified","emailed","features","skipped","flagOn","flagValues","page","ref","collection","tasks","where","orderBy","limit","get","doc","docs","push","title","String","kind","dueAtMs","remindAtMs","Number","assigneeUid","hostId","contactId","undefined","companyId","dealId","tasksTruncated","length","report","members","rows","settle","batch","task","update","reminderSentAtMs","commit","byAssignee","Map","unassigned","mine","set","size","listed","filter","member","orgSuspended","reachable","Set","map","$id","has","memberById","uids","keys","sort","userDocs","getAll","uid","userById","hostIds","Boolean","hostDocs","hostById","branding","orgSlug","workspaceName","trim","orgHub","origin","ORG_HOME","hubUrl","subdomain","HOST_DASHBOARD","host","taskUrl","hub","link","slice","indexOf","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","reminderEmail","poolLookups","row","userDoc","emailReason","address","email","toLowerCase","includes","pooled","catch","record","recipients","reminderInput","productName","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","crmTaskRemindersRoute","request","method","headers","rawHeaders","query","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","cursor","orgQuery","FieldPath","documentId","startAfter","hasMore","orgs","failures","console","message","failed","Object","swept","nextCursor","done"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAGEA,UAAU,EACVC,0BAA0B,EAC1BC,+BAA+B,EAC/BC,6BAA6B,EAC7BC,eAAe,EACfC,yBAAyB,EAEzBC,kBAAkB,EAClBC,mBAAmB,EACnBC,qBAAqB,EACrBC,iBAAiB,EACjBC,4BAA4B,EAC5BC,oBAAoB,EACpBC,sBAAsB,EACtBC,oBAAoB,EACpBC,sBAAsB,EACtBC,KAAK,QACA,sBAAqB;AAC5B,SACEC,iBAAiB,QACZ,yBAAqB;AAC5B,SAASC,oBAAoB,EAAEC,SAAS,QAAQ,2BAA0B;AAC1E,SAASC,2BAA2B,QAAQ,+CAA8C;AAC1F,SACEC,wBAAwB,EACxBC,aAAa,EACbC,0BAA0B,EAC1BC,cAAc,EACdC,sBAAsB,EACtBC,kBAAkB,EAClBC,WAAW,QACN,2BAA0B;AACjC,gEAAgE;AAChE,wEAAwE;AACxE,wEAAwE;AACxE,yDAAyD;AACzD,SAASC,sBAAsB,QAAQ,oDAAmD;AAC1F,SACEC,eAAe,EACfC,uBAAuB,EACvBC,gBAAgB,EAChBC,kBAAkB,QAEb,sDAAqD;AAC5D,SAASC,gBAAgB,EAAEC,YAAY,QAAQ,4CAA2C;AAC1F,SAASC,aAAa,QAAQ,uCAAsC;AACpE,SAASC,gBAAgB,QAAQ,4CAA2C;AAC5E,SAASC,yBAAyBC,aAAa,QAAQ,wCAAuC;AAC9F,SAASC,cAAc,QAAQ,0BAAsB;AAErD;;;;;CAKC,GACD,eAAeC,eAAeC,KAAa;IACzC,IAAI;QACF,MAAMN,cAAcb,cAAcoB,GAAG,GAAGC,SAAS,IAAIF;IACvD,EAAE,eAAM;IACN,sEAAsE;IACxE;AACF;AAEA,uKAAuK;AAEvK;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+CC,GAED,wEAAwE,GACxE,OAAO,MAAMG,4BAA4B,qBAAoB;AAE7D;;;;;;;CAOC,GACD,OAAO,MAAMC,+BAA+B,IAAG;AAE/C;;;;CAIC,GACD,OAAO,MAAMC,qCAAqC,EAAC;AA6CnD,eAAeC,UAAUC,GAAiB,EAAEC,MAAgB;QAG7CA,cAkHUC,WACMA;IArH7B,MAAM,EAAEP,SAAS,EAAEQ,KAAK,EAAEC,QAAQ,EAAEC,MAAM,EAAE,GAAGL;IAC/C,MAAMM,QAAQL,OAAOM,EAAE;IACvB,MAAML,OAAOD,eAAAA,OAAOO,IAAI,cAAXP,eAAiB,CAAC;IAC/B,MAAMQ,QAAmB;QAAEC,KAAK;QAAGC,SAAS;QAAGC,UAAU;QAAGC,SAAS;IAAE;IAEvE,IAAI,CAAC5C,kBAAkBF,uBAAuBmC,KAAKY,QAAQ,GAAG;QAC5D,OAAO,aAAKL;YAAOM,SAAS;;IAC9B;IACA,MAAMC,SAASvD,sBACb,eACAuC,IAAIiB,UAAU,CAAC,cAAc,EAC7BX,OACA3C,6BAA6BuC,GAAG,CAAC,eAAe,GAChDpC,qBAAqBoC;IAEvB,IAAI,CAACc,QAAQ,OAAO,aAAKP;QAAOM,SAAS;;IAEzC;;;;;;GAMC,GACD,MAAMG,OAAO,MAAMjB,OAAOkB,GAAG,CAC1BC,UAAU,CAAC/D,gBAAgBgE,KAAK,EAChCC,KAAK,CAAC,UAAU,MAAM,QACtBA,KAAK,CAAC,cAAc,MAAMnB,OAC1BoB,OAAO,CAAC,cAAc,QACtBC,KAAK,CAAClE,2BACNmE,GAAG;IACN,MAAMf,MAAiB,EAAE;IACzB,KAAK,MAAMgB,OAAOR,KAAKS,IAAI,CAAE;YAMXnB,aACPA,YAGaA,mBACLA;QAVjB,MAAMA,OAAOkB,IAAIlB,IAAI;QACrB,IAAI,CAACjD,mBAAmBiD,MAAkDL,QAAQ;QAClFO,IAAIkB,IAAI,CAAC;YACPT,KAAKO,IAAIP,GAAG;YACZZ,IAAImB,IAAInB,EAAE;YACVsB,OAAOC,QAAOtB,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;YAC/BuB,IAAI,GAAGvB,aAAAA,IAAI,CAAC,OAAO,YAAZA,aAA4C;YACnDwB,SAAS,OAAOxB,IAAI,CAAC,UAAU,KAAK,WAAYA,IAAI,CAAC,UAAU,GAAc;YAC7EyB,YAAYC,OAAO1B,IAAI,CAAC,aAAa;YACrC2B,aAAaL,QAAOtB,oBAAAA,IAAI,CAAC,cAAc,YAAnBA,oBAAuB;YAC3C4B,QAAQN,QAAOtB,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB;YACjC6B,WAAW,AAAC7B,IAAI,CAAC,YAAY,IAAe8B;YAC5CC,WAAW,AAAC/B,IAAI,CAAC,YAAY,IAAe8B;YAC5CE,QAAQ,AAAChC,IAAI,CAAC,SAAS,IAAe8B;QACxC;IACF;IACA,MAAMG,iBAAiBvB,KAAKS,IAAI,CAACe,MAAM,IAAIpF;IAC3C,IAAI,CAACoD,IAAIgC,MAAM,EAAE;QACf,OAAO,aAAKjC;YAAOM,SAAS;WAAmB0B,iBAAiB;YAAEA;QAAe,IAAI,CAAC;IACxF;IAEA,MAAME,SAAoB;QAAEjC,KAAKA,IAAIgC,MAAM;QAAE/B,SAAS;QAAGC,UAAU;QAAGC,SAAS;QAAG+B,SAAS,CAAC;IAAE;IAC9F,IAAIH,gBAAgBE,OAAOF,cAAc,GAAG;IAC5C,MAAMI,OAAOF,OAAOC,OAAO;IAE3B;;;;GAIC,GACD,MAAME,SAAS,OAAOzB;QACpBsB,OAAOhC,OAAO,IAAIU,MAAMqB,MAAM;QAC9B,IAAIrC,QAAQ;QACZ,MAAM0C,QAAQpD,UAAUoD,KAAK;QAC7B,KAAK,MAAMC,QAAQ3B,MAAO0B,MAAME,MAAM,CAACD,KAAK7B,GAAG,EAAE;YAAE+B,kBAAkB/C;QAAM;QAC3E,MAAM4C,MAAMI,MAAM;IACpB;IAEA,MAAMC,aAAa,IAAIC;IACvB,MAAMC,aAAwB,EAAE;IAChC,KAAK,MAAMN,QAAQtC,IAAK;YAKT0C;QAJb,IAAI,CAACJ,KAAKb,WAAW,EAAE;YACrBmB,WAAW1B,IAAI,CAACoB;YAChB;QACF;QACA,MAAMO,QAAOH,kBAAAA,WAAW3B,GAAG,CAACuB,KAAKb,WAAW,aAA/BiB,kBAAoC,EAAE;QACnDG,KAAK3B,IAAI,CAACoB;QACVI,WAAWI,GAAG,CAACR,KAAKb,WAAW,EAAEoB;IACnC;IACA,wEAAwE;IACxE,uEAAuE;IACvE,IAAID,WAAWZ,MAAM,EAAE;QACrBG,IAAI,CAAC,GAAG,GAAG;YAAExB,OAAOiC,WAAWZ,MAAM;YAAE3B,SAAS;QAAa;QAC7D,MAAM+B,OAAOQ;IACf;IACA,IAAI,CAACF,WAAWK,IAAI,EAAE,OAAOd;IAE7B,MAAMe,SAAS,AAAC,CAAA,MAAMlF,eAAe8B,MAAK,EAAGqD,MAAM,CACjD,CAACC,SAAWA,OAAOC,YAAY,KAAK;IAEtC,yEAAyE;IACzE,gBAAgB;IAChB,MAAMC,YAAY,IAAIC,IACpB,MAAM3F,4BAA4BsF,OAAOM,GAAG,CAAC,CAACJ,SAAWA,OAAOK,GAAG;IAErE,MAAMrB,UAAUc,OAAOC,MAAM,CAAC,CAACC,SAAWE,UAAUI,GAAG,CAACN,OAAOK,GAAG;IAClE,MAAME,aAAa,IAAId,IAAIT,QAAQoB,GAAG,CAAC,CAACJ,SAAW;YAACA,OAAOK,GAAG;YAAEL;SAAO;IACvE,qEAAqE;IACrE,uCAAuC;IACvC,MAAMQ,OAAO;WAAIhB,WAAWiB,IAAI;KAAG,CAACC,IAAI;IACxC,MAAMC,WAAW,MAAM5E,UAAU6E,MAAM,IAClCJ,KAAKJ,GAAG,CAAC,CAACS,MAAQ9E,UAAUyB,UAAU,CAAC,SAASM,GAAG,CAAC+C;IAEzD,MAAMC,WAAW,IAAIrB,IAAIkB,SAASP,GAAG,CAAC,CAACtC,MAAQ;YAACA,IAAInB,EAAE;YAAEmB;SAAI;IAE5D,yEAAyE;IACzE,MAAMiD,UAAU;WAAI,IAAIZ,IAAIrD,IAAIsD,GAAG,CAAC,CAAChB,OAASA,KAAKZ,MAAM,EAAEuB,MAAM,CAACiB;KAAU;IAC5E,MAAMC,WAAWF,QAAQjC,MAAM,GAC3B,MAAM/C,UAAU6E,MAAM,IAAIG,QAAQX,GAAG,CAAC,CAAC5B,SAAWzC,UAAUyB,UAAU,CAAC,SAASM,GAAG,CAACU,YACpF,EAAE;IACN,MAAM0C,WAAW,IAAIzB,IAAIwB,SAASb,GAAG,CAAC,CAACtC,MAAQ;YAACA,IAAInB,EAAE;YAAEmB;SAAI;IAE5D,MAAMqD,WAAWlH,uBAAuBqC;IACxC,MAAM8E,UAAUlD,QAAO5B,YAAAA,GAAG,CAAC,OAAO,YAAXA,YAAe;IACtC,MAAM+E,gBAAgBnD,QAAO5B,YAAAA,GAAG,CAAC,OAAO,YAAXA,YAAe,IAAIgF,IAAI,MAAMF;IAC1D,MAAMG,SAASH,UAAU,GAAGhF,IAAIoF,MAAM,GAAGnI,WAAWe,MAAMqH,QAAQ,EAAE;QAAEL;IAAQ,GAAG,IAAI,CAAC,GAAG;IACzF,MAAMM,SAAS,CAAClD;;YAEW0C;QADzB,IAAI,CAAC1C,QAAQ,OAAO+C;QACpB,MAAMI,YAAYzD,gBAAOgD,gBAAAA,SAASrD,GAAG,CAACW,4BAAb0C,cAAsBrD,GAAG,CAAC,+BAAgB;QACnE,IAAI,CAACuD,WAAW,CAACO,WAAW,OAAO;QACnC,OAAO,GAAGvF,IAAIoF,MAAM,GAAGnI,WAAWe,MAAMwH,cAAc,EAAE;YAAER;YAASS,MAAMF;QAAU,GAAG,IAAI,CAAC;IAC7F;IACA,uEAAuE;IACvE,sEAAsE;IACtE,MAAMG,UAAU,CAAC1C;QACf,MAAM2C,MAAML,OAAOtC,KAAKZ,MAAM;QAC9B,IAAI,CAACuD,KAAK,OAAO3F,IAAIoF,MAAM;QAC3B,MAAMQ,OAAOpI,oBAAoBwF,KAAKZ,MAAM,IAAI,MAAMY;QACtD,OAAO,GAAG2C,MAAMC,KAAKC,KAAK,CAACD,KAAKE,OAAO,CAAC,UAAU,OAAOpD,MAAM,GAAG;IACpE;IACA,MAAMqD,cAAc,GAAG/F,IAAIoF,MAAM,GAAGnI,WAAWe,MAAMgI,4BAA4B,GAAG;IACpF,MAAMC,cAAc7G,iBAAiB2F;IACrC,MAAMmB,QAAQnH,iBAAiBmB;IAC/B,IAAIiG;IACJ,IAAIC,cAAc;IAElB,KAAK,MAAM3B,OAAOL,KAAM;YAoCDR;QAnCrB,MAAMvC,QAAQ+B,WAAW3B,GAAG,CAACgD;QAC7B,MAAM4B,MAAoB;YAAEhF,OAAOA,MAAMqB,MAAM;QAAC;QAChDG,IAAI,CAAC4B,IAAI,GAAG4B;QACZ,MAAMzC,SAASO,WAAW1C,GAAG,CAACgD;QAC9B,IAAI,CAACb,QAAQ;YACXyC,IAAItF,OAAO,GAAG;YACd,MAAM+B,OAAOzB;YACb;QACF;QACA,IAAI,CAAE,MAAM5C,uBAAuB6B,OAAOsD,QAA0B,gBAAiB;YACnFyC,IAAItF,OAAO,GAAG;YACd,MAAM+B,OAAOzB;YACb;QACF;QACA,MAAMiF,UAAU5B,SAASjD,GAAG,CAACgD;QAC7B,IAAI/G,kBAAkB4I,2BAAAA,QAAS7E,GAAG,CAAC,sBAAsB,yBAAyB;YAChF4E,IAAItF,OAAO,GAAG;YACd,MAAM+B,OAAOzB;YACb;QACF;QACA,IAAIhB,QAAQ;YACVgG,IAAIzF,QAAQ,GAAGS,MAAMqB,MAAM;YAC3B2D,IAAIxF,OAAO,GAAG;YACd,MAAMiC,OAAOzB;YACb;QACF;QAEA;;;;;KAKC,GACD,IAAIR,UAAU;QACd,IAAI0F;QACJ,IAAIC,UAAU1E,QAAO8B,gBAAAA,OAAO6C,KAAK,YAAZ7C,gBAAgB,IAAIsB,IAAI,GAAGwB,WAAW;QAC3D,IAAI,CAACF,QAAQG,QAAQ,CAAC,QAAQP,cAActG,oCAAoC;;gBAG7D8G;YAFjBR,eAAe;YACf,MAAMQ,SAAS,MAAMvI,yBAAyBoG,KAAKoC,KAAK,CAAC,IAAM;YAC/DL,UAAU1E,eAAO8E,2BAAAA,iBAAAA,OAAQE,MAAM,qBAAdF,eAAgBH,KAAK,mBAAI,IAAIvB,IAAI,GAAGwB,WAAW;QAClE;QACA,MAAMK,aAAaP,QAAQG,QAAQ,CAAC,OAChC,MAAM/H,uBAAuB;YAAC4H;SAAQ,EAAE7G,aACxC,EAAE;QACN,IAAI,CAACoH,WAAWrE,MAAM,EAAE;YACtB6D,cAAcC,QAAQG,QAAQ,CAAC,OAAO,eAAe;QACvD,OAAO;YACL,MAAMK,gBAAgB;gBACpB3F;gBACAjB;gBACA6G,aAAalC,SAASkC,WAAW;gBACjChC;gBACAS;gBACAK;gBACAE;YACF;YACA,MAAMiB,UAAU9J,8BAA8BiE;YAC9C,uEAAuE;YACvE,6CAA6C;YAC7C8E,wBAAAA,gBAAAA,gBAAkBtH,gBAAgB,qBAAqBgI,KAAK,CAAC,IAAM;YACnE,MAAMM,SAAS,MAAMhB;YACrB,MAAMiB,WAAWD,SACbrI,wBACEqI,QACA,aACKjB,MAAMmB,KAAK;gBACd,oBAAoBH;gBACpB,iBAAiB/J,gCAAgC,aAAK6J;oBAAeM,aAAa;;gBAClFvB;gBAEFG,MAAMqB,OAAO,IAEf;YACJ,MAAMC,SAAS,MAAMrJ,UAAU;gBAC7BsJ,IAAIV;eACD/H,mBAAmBoI,UAAU;gBAC9BF;gBACAQ,MAAMvK,gCAAgC6J;YACxC;gBACAW,UAAU5C,SAAS4C,QAAQ;gBAC3BC,SAAS;gBACTC,UAAU;;YAEZ,IAAIL,OAAOM,IAAI,EAAE;gBACfjH,UAAU;gBACV,MAAMnC,qBAAqBmI,KAAK,CAAC,IAAMvE;YACzC,OAAO,IAAIpE,qBAAqBsJ,YAAY,MAAM;gBAChDnB,IAAI0B,QAAQ,GAAG;gBACfpF,OAAOoF,QAAQ,GAAG;gBAClB,OAAOpF;YACT,OAAO;oBACS;gBAAd4D,eAAc,iBAAA,AAACiB,OAA+BQ,MAAM,YAAtC,iBAA0C;YAC1D;QACF;QAEA,8DAA8D;QAC9D,KAAK,MAAMhF,QAAQ3B,MAAO;YACxB,MAAM1C,YAAY;gBAAC8F;aAAI,EAAE;gBACvBwD,MAAM;gBACNpG,OAAO;gBACPqG,MAAMhL,2BAA2B8F,MAAM5C,UAAU6E;gBACjDW,MAAMpI,oBAAoBwF,KAAKZ,MAAM,IAAI,MAAMY;gBAC/C1C;eACI0C,KAAKZ,MAAM,GAAG;gBAAEA,QAAQY,KAAKZ,MAAM;YAAC,IAAI,CAAC;QAEjD;QACA,MAAMU,OAAOzB;QACbgF,IAAIzF,QAAQ,GAAGS,MAAMqB,MAAM;QAC3B2D,IAAIxF,OAAO,GAAGA;QACd,IAAI0F,aAAaF,IAAIE,WAAW,GAAGA;QACnC5D,OAAO/B,QAAQ,IAAIS,MAAMqB,MAAM;QAC/B,IAAI7B,SAAS8B,OAAO9B,OAAO,IAAI;IACjC;IACA,OAAO8B;AACT;AAEA,OAAO,eAAewF,sBAAsBC,OAAgB;IAC1D,MAAM,EAAEC,MAAM,EAAEH,IAAI,EAAEI,SAASC,UAAU,EAAEC,KAAK,EAAE,GAAG,MAAM5K,qBAAqBwK;IAChF,MAAME,UAAUC;IAChB,IAAIF,WAAW,UAAUA,WAAW,OAAO;QACzC,OAAOI,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAqB,GAAG;YAAEC,QAAQ;QAAI;IACtE;IACA,IAAI,CAACC,QAAQC,GAAG,CAACC,WAAW,EAAE;QAC5B,OAAON,SAASC,IAAI,CAClB;YAAEC,OAAO;QAAmD,GAC5D;YAAEC,QAAQ;QAAI;IAElB;IACA,IAAI,CAAC3J,iBAAiBqJ,UAAU;QAC9B,OAAOG,SAASC,IAAI,CAAC;YAAEC,OAAO;QAAkB,GAAG;YAAEC,QAAQ;QAAI;IACnE;IACA,uEAAuE;IACvE,oEAAoE;IACpE,iDAAiD;IACjD,MAAMvI,SAASnB,aAAa;QAAEmJ;QAAQH;QAAMM;IAAM;IAClD,sEAAsE;IACtE,yBAAyB;IACzB,IAAIH,WAAW,QAAQ,MAAM7I,eAAeI;IAE5C,IAAI;;YAyE6CoJ;QAxE/C,MAAMrJ,YAAYrB,cAAcoB,GAAG,GAAGC,SAAS;QAC/C,MAAMQ,QAAQ8I,KAAKC,GAAG;QACtB,MAAMlJ,MAAoB;YACxBL;YACAQ;YACAC,UAAUb;YACVc;YACAY,YAAY,MAAM1C;YAClB6G,QAAQ9F;QACV;QAEA,sEAAsE;QACtE,wEAAwE;QACxE,wDAAwD;QACxD,MAAM6J,iBAAiBjH,OAAQgG,wBAAD,AAACA,KAA0C1G,KAAK;QAC9E,MAAM4H,WACJlH,OAAOmH,QAAQ,CAACF,mBAAmBA,iBAAiB,IAChDG,KAAKC,GAAG,CAACD,KAAKE,KAAK,CAACL,iBAAiBtJ,gCACrCA;QACN,MAAM4J,YAAavB,wBAAD,AAACA,KAA2CwB,MAAM;QACpE,MAAMA,SACJ,OAAOD,cAAc,YAAYA,UAAU/G,MAAM,GAAG,KAAK,CAAC+G,UAAU9C,QAAQ,CAAC,OACzE8C,YACA;QACN,IAAIE,WAAWhK,UACZyB,UAAU,CAAC,QACXG,OAAO,CAACjD,cAAcqB,SAAS,CAACiK,SAAS,CAACC,UAAU,IACpDrI,KAAK,CAAC4H,WAAW;QACpB,IAAIM,QAAQC,WAAWA,SAASG,UAAU,CAACnK,UAAUyB,UAAU,CAAC,QAAQM,GAAG,CAACgI;QAC5E,MAAMxI,OAAO,MAAMyI,SAASlI,GAAG;QAC/B,MAAMsI,UAAU7I,KAAKS,IAAI,CAACe,MAAM,GAAG0G;QACnC,MAAMJ,UAAUe,UAAU7I,KAAKS,IAAI,CAACkE,KAAK,CAAC,GAAGuD,YAAYlI,KAAKS,IAAI;QAElE,MAAMqI,OAAkC,CAAC;QACzC,MAAMC,WAAmC,CAAC;QAC1C,IAAIvJ,MAAM;QACV,IAAIC,UAAU;QACd,IAAIC,WAAW;QACf,IAAIC,UAAU;QACd,IAAIkH,WAAW;QACf,KAAK,MAAM9H,UAAU+I,QAAS;YAC5B,IAAI;gBACF,MAAMrG,SAAS,MAAM5C,UAAUC,KAAKC;gBACpC+J,IAAI,CAAC/J,OAAOM,EAAE,CAAC,GAAGoC;gBAClBjC,OAAOiC,OAAOjC,GAAG;gBACjBC,WAAWgC,OAAOhC,OAAO;gBACzBC,YAAY+B,OAAO/B,QAAQ;gBAC3BC,WAAW8B,OAAO9B,OAAO;gBACzB,IAAI8B,OAAOoF,QAAQ,EAAE;oBACnBA,WAAW;oBACX;gBACF;YACF,EAAE,OAAOY,OAAO;;gBACduB,QAAQvB,KAAK,CAAC,uCAAuC1I,OAAOM,EAAE,EAAEoI;gBAChEsB,QAAQ,CAAChK,OAAOM,EAAE,CAAC,YAAIoI,yBAAD,AAACA,MAAiBwB,OAAO,oBAAI;YACrD;QACF;QACA,MAAMC,SAASC,OAAOhG,IAAI,CAAC4F;QAC3B,OAAOxB,SAASC,IAAI,CAClB;YACEtI,UAAUJ,IAAII,QAAQ;YACtBC;YACAiK,OAAOtB,QAAQtG,MAAM;YACrBhC;YACAC;YACAC;YACAC;YACAmJ;WACIjC,WAAW;YAAEA,UAAU;QAAK,IAAI,CAAC,GACjCqC,OAAO1H,MAAM,GAAG;YAAEuH;QAAS,IAAI,CAAC;YACpC,iEAAiE;YACjE,8DAA8D;YAC9DM,YAAYxC,YAAY,CAACgC,UAAU,gBAAQf,YAAAA,OAAO,CAACA,QAAQtG,MAAM,GAAG,EAAE,qBAA3BsG,UAA6BzI,EAAE,mBAAI;YAC9EiK,MAAMzC,YAAY,CAACgC;YAErB,uEAAuE;QACvE,sDAAsD;QACtD;YAAEnB,QAAQwB,OAAO1H,MAAM,GAAG,MAAM;QAAI;IAExC,EAAE,OAAOiG,OAAO;QACduB,QAAQvB,KAAK,CAAC,mCAAmCA;QACjD,OAAOF,SAASC,IAAI,CAAC;YAAEC,OAAO;QAA4B,GAAG;YAAEC,QAAQ;QAAI;IAC7E;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-reminders-route.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 buildRoute,\n CRM_COLLECTIONS,\n isReleaseFlagOnForOrg,\n notificationMuted,\n parseOrgReleaseFlagOverrides,\n pluginRequestFromWeb,\n resolveBrandingProfile,\n resolveEffectivePlan,\n resolveOrgEntitlements,\n Route,\n} from '@aglyn/aglyn/server'\nimport {\n crmDigestEntitled,\n} from '../model/crm-digest'\nimport { rateLimitedRetryAtMs, sendEmail } from '@aglyn/shared-util-email'\nimport { withoutMailWithheldAccounts } from '@aglyn/tenant-data-admin/server/account-mail'\nimport {\n findUserByUidAcrossPools,\n firebaseAdmin,\n getServerReleaseFlagValues,\n listOrgMembers,\n memberHasOrgPermission,\n meterPlatformEmail,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\n// From the LEAF, not the barrel (AGL-2407): the cron specs mock\n// `@aglyn/tenant-data-admin` wholesale, and a suppression gate imported\n// through the barrel would be silently replaced by whatever the factory\n// lists. Same reasoning as the digest beside this route.\nimport { filterSuppressedEmails } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport {\n loadSystemEmail,\n renderLoadedSystemEmail,\n systemEmailBrand,\n systemEmailContent,\n type LoadedSystemEmail,\n} from '@aglyn/tenant-data-admin/server/render-system-email'\nimport { isCronAuthorized, isCronDryRun } from '@aglyn/tenant-data-admin/server/cron-auth'\nimport { writeCronBeat } from '@aglyn/aglyn/app-utils/health-report'\nimport { brandSupportLine } from '@aglyn/aglyn/app-utils/brand-support-line'\nimport { platformConsoleOrigin as consoleOrigin } from '@aglyn/aglyn/app-utils/platform-brand'\nimport { digestTimeZone } from './daily-digest-route'\nimport {\n composeCrmTaskReminderBody,\n composeCrmTaskReminderEmailText,\n composeCrmTaskReminderSubject,\n CRM_TASK_REMINDER_CEILING,\n type CrmReminderTask,\n crmTaskReminderDue,\n crmTaskReminderLink,\n} from '../model/crm-task-reminders'\n\n/**\n * The mark a scheduled run leaves for `/api/health/crons` (AGL-1955), on the\n * INVOCATION and before the work — the console's rule for every cron. Never\n * throws: reaching the admin app is inside the try, because a monitor that\n * can take down the job it describes is worse than the failure it watches.\n */\nasync function recordCronBeat(jobId: string): Promise<void> {\n try {\n await writeCronBeat(firebaseAdmin.app().firestore(), jobId)\n } catch {\n // Deliberately swallowed: a job that stops stamping reds its own row.\n }\n}\n\n// lockdown-423: exempt — server-internal cron (x-cron-secret), no user caller; it reads an org's CRM to remind its members and writes nothing a locked org could lose.\n\n/**\n * A task's reminder at its own time (AGL-2659): `POST /api/crm/task-reminders`.\n *\n * The digest beside this route says once a morning what is late and what\n * is due today; nothing said \"the 3:00 call is at 3:00\". Every hour this\n * reads, per org, the open tasks whose `remindAtMs` has come and tells\n * each one's assignee ONCE — a console notification per task\n * (`content.taskReminder`, opening the record the task is for) and one\n * email per member per run listing everything of theirs that fell due.\n * The words and the rule live in `crm-task-reminders.ts`; this file is the\n * reads, the sends and the sweep, in the digest's shape wherever the two\n * do the same thing.\n *\n * ## Who is told, and who is not\n *\n * The task's ASSIGNEE, and nobody else — an unassigned task is a deliberate\n * choice in the drawer, as the digest reads it. Only a member who may open\n * the CRM (`data.manage`, resolved through their custom role), only in an\n * org whose plan carries the suite and whose `release_crm` flag is\n * on: the plan gate is the digest's own (`crmDigestEntitled`), so a plan\n * that gets no digest gets no reminders either. The recipient's\n * operational-category mute (`notificationPrefs.content`) governs the WHOLE\n * reminder, mail included — unlike the digest, which has a switch of its\n * own for the schedule it keeps; a reminder is one message about one task.\n *\n * ## Once, and resumable\n *\n * `reminderSentAtMs` on the task is the idempotence: stamped in one batch\n * with the member's sends, so a rerun over the same hour reads the mark and\n * sends nothing, and stamped on every reminder the run HANDLED — sent, or\n * found nobody to send it to — so a reminder nobody can be told about does\n * not come back every hour. A reminder is left unstamped only when the run\n * never reached a verdict: the send-rate governor refused, or the org\n * threw. Orgs are swept in id order behind a cursor, as the digest sweeps\n * them, so the workflow's `done:false` loop carries a platform of any\n * size across invocations.\n *\n * The task query reads the NEWEST reminders that have come due, not the\n * oldest — see `CRM_TASK_REMINDER_CEILING` for why that is what makes a\n * bounded page safe against a window that only ever grows.\n *\n * The email is a `bulk` send under the platform send-rate governor\n * (AGL-2409), metered as PLATFORM mail, for the digest's reasons: a cron\n * fan-out on the shared sending domain, and the platform reminding a\n * member is not the org's sending. A refusal STOPS the run and reports\n * `done: true`; the refused member was never stamped, and the next hour's\n * run finds their reminders at the top of the window again.\n */\n\n/** The beat `/api/health/crons` reads — the `SCHEDULED_JOBS` row's id. */\nexport const CRM_TASK_REMINDERS_JOB_ID = 'crm-task-reminders'\n\n/**\n * Orgs per invocation.\n *\n * More than the digest's twenty-five: an org here costs one task query and,\n * only when something fell due, the roster and the sends. The common hour\n * finds nothing due in most orgs, and a chunk that reads a hundred empty\n * queries finishes well inside `maxDuration = 60`.\n */\nexport const CRM_TASK_REMINDERS_ORG_CHUNK = 100\n\n/**\n * The most members per org whose address is chased through the auth pools\n * when their member document carries none — the digest's bound, for the\n * digest's reason: work, not audience.\n */\nexport const CRM_TASK_REMINDERS_MAX_UID_LOOKUPS = 5\n\ntype Firestore = FirebaseFirestore.Firestore\ntype Snapshot = FirebaseFirestore.QueryDocumentSnapshot\n\n/** What one member's row in the response says. */\ninterface MemberReport {\n tasks: number\n skipped?: string\n notified?: number\n emailed?: boolean\n emailReason?: string\n deferred?: boolean\n}\n\ninterface OrgReport {\n skipped?: string\n /** Reminders that had come due and were not yet handled. */\n due: number\n /** Reminders this run stamped as handled. */\n handled: number\n /** Console notifications sent. */\n notified: number\n /** Emails sent — one per member per run. */\n emailed: number\n members?: Record<string, MemberReport>\n tasksTruncated?: boolean\n /** The send-rate governor refused; the sweep stops here. */\n deferred?: boolean\n}\n\ninterface SweepContext {\n firestore: Firestore\n nowMs: number\n timeZone: string\n dryRun: boolean\n flagValues: Awaited<ReturnType<typeof getServerReleaseFlagValues>>\n origin: string\n}\n\n/** A due reminder with the document it came from, so the stamp has a target. */\ninterface DueTask extends CrmReminderTask {\n ref: FirebaseFirestore.DocumentReference\n}\n\nasync function remindOrg(ctx: SweepContext, orgDoc: Snapshot): Promise<OrgReport> {\n const { firestore, nowMs, timeZone, dryRun } = ctx\n const orgId = orgDoc.id\n const org = (orgDoc.data() ?? {}) as Partial<AglynOrgBilling> & Record<string, unknown>\n const quiet: OrgReport = { due: 0, handled: 0, notified: 0, emailed: 0 }\n\n if (!crmDigestEntitled(resolveOrgEntitlements(org).features)) {\n return { ...quiet, skipped: 'not-entitled' }\n }\n const flagOn = isReleaseFlagOnForOrg(\n 'release_crm',\n ctx.flagValues['release_crm'],\n orgId,\n parseOrgReleaseFlagOverrides(org['releaseFlags']),\n resolveEffectivePlan(org),\n )\n if (!flagOn) return { ...quiet, skipped: 'release-flag' }\n\n /*\n * Every open task whose reminder has come, newest first, on\n * `(status, remindAtMs desc)`. The \"not yet handled\" half is a fact about\n * a field's absence, which a query cannot select on, so it is asked of\n * the page in memory — and the page being the NEWEST five hundred is what\n * keeps the handled ones from crowding out the next one due.\n */\n const page = await orgDoc.ref\n .collection(CRM_COLLECTIONS.tasks)\n .where('status', '==', 'open')\n .where('remindAtMs', '<=', nowMs)\n .orderBy('remindAtMs', 'desc')\n .limit(CRM_TASK_REMINDER_CEILING)\n .get()\n const due: DueTask[] = []\n for (const doc of page.docs) {\n const data = doc.data() as Record<string, unknown>\n if (!crmTaskReminderDue(data as Parameters<typeof crmTaskReminderDue>[0], nowMs)) continue\n due.push({\n ref: doc.ref,\n id: doc.id,\n title: String(data['title'] ?? ''),\n kind: (data['kind'] as CrmReminderTask['kind']) ?? 'todo',\n dueAtMs: typeof data['dueAtMs'] === 'number' ? (data['dueAtMs'] as number) : null,\n remindAtMs: Number(data['remindAtMs']),\n assigneeUid: String(data['assigneeUid'] ?? ''),\n hostId: String(data['hostId'] ?? ''),\n contactId: (data['contactId'] as string) || undefined,\n companyId: (data['companyId'] as string) || undefined,\n dealId: (data['dealId'] as string) || undefined,\n })\n }\n const tasksTruncated = page.docs.length >= CRM_TASK_REMINDER_CEILING\n if (!due.length) {\n return { ...quiet, skipped: 'nothing-due', ...(tasksTruncated ? { tasksTruncated } : {}) }\n }\n\n const report: OrgReport = { due: due.length, handled: 0, notified: 0, emailed: 0, members: {} }\n if (tasksTruncated) report.tasksTruncated = true\n const rows = report.members as Record<string, MemberReport>\n\n /**\n * Stamp a set of reminders as handled, in one batch, unless this is a\n * plan. Everything a member's verdict covers lands together, which is\n * what makes a rerun read the same answer for all of them.\n */\n const settle = async (tasks: DueTask[]): Promise<void> => {\n report.handled += tasks.length\n if (dryRun) return\n const batch = firestore.batch()\n for (const task of tasks) batch.update(task.ref, { reminderSentAtMs: nowMs })\n await batch.commit()\n }\n\n const byAssignee = new Map<string, DueTask[]>()\n const unassigned: DueTask[] = []\n for (const task of due) {\n if (!task.assigneeUid) {\n unassigned.push(task)\n continue\n }\n const mine = byAssignee.get(task.assigneeUid) ?? []\n mine.push(task)\n byAssignee.set(task.assigneeUid, mine)\n }\n // A reminder for nobody is handled by saying so: it would otherwise sit\n // at the top of the window every hour for as long as the task is open.\n if (unassigned.length) {\n rows[''] = { tasks: unassigned.length, skipped: 'unassigned' }\n await settle(unassigned)\n }\n if (!byAssignee.size) return report\n\n const listed = (await listOrgMembers(orgId)).filter(\n (member) => member.orgSuspended !== true,\n )\n // A locked or disabled account is not told (AGL-3418): it cannot sign in\n // to act on it.\n const reachable = new Set(\n await withoutMailWithheldAccounts(listed.map((member) => member.$id)),\n )\n const members = listed.filter((member) => reachable.has(member.$id))\n const memberById = new Map(members.map((member) => [member.$id, member]))\n // Sorted so the order members are told in — and the member a refusal\n // lands on — is the same on every run.\n const uids = [...byAssignee.keys()].sort()\n const userDocs = await firestore.getAll(\n ...uids.map((uid) => firestore.collection('users').doc(uid)),\n )\n const userById = new Map(userDocs.map((doc) => [doc.id, doc]))\n\n // The sites the due tasks name, for the absolute links the mail carries.\n const hostIds = [...new Set(due.map((task) => task.hostId).filter(Boolean))]\n const hostDocs = hostIds.length\n ? await firestore.getAll(...hostIds.map((hostId) => firestore.collection('hosts').doc(hostId)))\n : []\n const hostById = new Map(hostDocs.map((doc) => [doc.id, doc]))\n\n const branding = resolveBrandingProfile(org)\n const orgSlug = String(org['slug'] ?? '')\n const workspaceName = String(org['name'] ?? '').trim() || orgSlug\n const orgHub = orgSlug ? `${ctx.origin}${buildRoute(Route.ORG_HOME, { orgSlug })}/crm` : null\n const hubUrl = (hostId: string): string | null => {\n if (!hostId) return orgHub\n const subdomain = String(hostById.get(hostId)?.get('subdomain') ?? '')\n if (!orgSlug || !subdomain) return null\n return `${ctx.origin}${buildRoute(Route.HOST_DASHBOARD, { orgSlug, host: subdomain })}/crm`\n }\n // The record the task is for, on its own hub — the notification's link\n // past the `/{hostId}/crm` or `/org/crm` prefix the console rewrites.\n const taskUrl = (task: CrmReminderTask): string => {\n const hub = hubUrl(task.hostId)\n if (!hub) return ctx.origin\n const link = crmTaskReminderLink(task.hostId || null, task)\n return `${hub}${link.slice(link.indexOf('/crm') + '/crm'.length)}`\n }\n const settingsUrl = `${ctx.origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n const supportLine = brandSupportLine(branding)\n const brand = systemEmailBrand(org)\n let reminderEmail: Promise<LoadedSystemEmail | null> | undefined\n let poolLookups = 0\n\n for (const uid of uids) {\n const tasks = byAssignee.get(uid) as DueTask[]\n const row: MemberReport = { tasks: tasks.length }\n rows[uid] = row\n const member = memberById.get(uid)\n if (!member) {\n row.skipped = 'not-a-member'\n await settle(tasks)\n continue\n }\n if (!(await memberHasOrgPermission(orgId, member as AglynOrgMember, 'data.manage'))) {\n row.skipped = 'no-data-manage'\n await settle(tasks)\n continue\n }\n const userDoc = userById.get(uid)\n if (notificationMuted(userDoc?.get('notificationPrefs'), 'content.taskReminder')) {\n row.skipped = 'muted'\n await settle(tasks)\n continue\n }\n if (dryRun) {\n row.notified = tasks.length\n row.emailed = true\n await settle(tasks)\n continue\n }\n\n /*\n * THE EMAIL FIRST, for the digest's reason: a refusal from the\n * send-rate governor stops the run with nothing else done for this\n * member — no notification to double next hour, no mark to skip them\n * on — so the order here is the idempotence, not a preference.\n */\n let emailed = false\n let emailReason: string | undefined\n let address = String(member.email ?? '').trim().toLowerCase()\n if (!address.includes('@') && poolLookups < CRM_TASK_REMINDERS_MAX_UID_LOOKUPS) {\n poolLookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n const recipients = address.includes('@')\n ? await filterSuppressedEmails([address], firestore)\n : []\n if (!recipients.length) {\n emailReason = address.includes('@') ? 'suppressed' : 'no-recipient'\n } else {\n const reminderInput = {\n tasks,\n timeZone,\n productName: branding.productName,\n workspaceName,\n taskUrl,\n settingsUrl,\n supportLine,\n }\n const subject = composeCrmTaskReminderSubject(tasks)\n // The `crm-task-reminder` system email (AGL-3367), in the org's brand,\n // read once per org and rendered per member.\n reminderEmail ??= loadSystemEmail('crm-task-reminder').catch(() => null)\n const loaded = await reminderEmail\n const rendered = loaded\n ? renderLoadedSystemEmail(\n loaded,\n {\n ...brand.merge,\n 'reminder.subject': subject,\n 'reminder.body': composeCrmTaskReminderEmailText({ ...reminderInput, omitClosing: true }),\n settingsUrl,\n },\n brand.options,\n )\n : null\n const result = await sendEmail({\n to: recipients,\n ...systemEmailContent(rendered, {\n subject,\n text: composeCrmTaskReminderEmailText(reminderInput),\n }),\n fromName: branding.fromName,\n context: 'crm-task-reminder',\n priority: 'bulk',\n })\n if (result.sent) {\n emailed = true\n await meterPlatformEmail().catch(() => undefined)\n } else if (rateLimitedRetryAtMs(result) !== null) {\n row.deferred = true\n report.deferred = true\n return report\n } else {\n emailReason = (result as { reason?: string }).reason ?? 'failed'\n }\n }\n\n // One notification per task: each opens the record it is for.\n for (const task of tasks) {\n await notifyUsers([uid], {\n type: 'content.taskReminder',\n title: 'Task reminder',\n body: composeCrmTaskReminderBody(task, timeZone, workspaceName),\n link: crmTaskReminderLink(task.hostId || null, task),\n orgId,\n ...(task.hostId ? { hostId: task.hostId } : {}),\n })\n }\n await settle(tasks)\n row.notified = tasks.length\n row.emailed = emailed\n if (emailReason) row.emailReason = emailReason\n report.notified += tasks.length\n if (emailed) report.emailed += 1\n }\n return report\n}\n\nexport async function crmTaskRemindersRoute(request: Request): Promise<Response> {\n const { method, body, headers: rawHeaders, query } = await pluginRequestFromWeb(request)\n const headers = rawHeaders as Partial<Record<string, string>>\n if (method !== 'POST' && method !== 'GET') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n if (!process.env.CRON_SECRET) {\n return Response.json(\n { error: 'Task reminders are not configured (CRON_SECRET).' },\n { status: 501 },\n )\n }\n if (!isCronAuthorized(headers)) {\n return Response.json({ error: 'Unauthenticated' }, { status: 401 })\n }\n // A GET reports the plan and sends nothing; a bodyless POST — what the\n // scheduled workflow sends — is a real run. `isCronDryRun` keys the\n // default on the METHOD for exactly that reason.\n const dryRun = isCronDryRun({ method, body, query })\n // Stamped on the invocation, not on the work (AGL-1955): an hour with\n // nothing due still ran.\n if (method === 'POST') await recordCronBeat(CRM_TASK_REMINDERS_JOB_ID)\n\n try {\n const firestore = firebaseAdmin.app().firestore()\n const nowMs = Date.now()\n const ctx: SweepContext = {\n firestore,\n nowMs,\n timeZone: digestTimeZone(),\n dryRun,\n flagValues: await getServerReleaseFlagValues(),\n origin: consoleOrigin(),\n }\n\n // The resumable sweep, in the shape the digest reads orgs (AGL-2220):\n // ordered by id, `limit + 1` to learn whether there is more, the cursor\n // validated as an org id before it becomes a reference.\n const requestedLimit = Number((body as { limit?: unknown } | undefined)?.limit)\n const pageSize =\n Number.isFinite(requestedLimit) && requestedLimit > 0\n ? Math.min(Math.floor(requestedLimit), CRM_TASK_REMINDERS_ORG_CHUNK)\n : CRM_TASK_REMINDERS_ORG_CHUNK\n const rawCursor = (body as { cursor?: unknown } | undefined)?.cursor\n const cursor =\n typeof rawCursor === 'string' && rawCursor.length > 0 && !rawCursor.includes('/')\n ? rawCursor\n : null\n let orgQuery = firestore\n .collection('orgs')\n .orderBy(firebaseAdmin.firestore.FieldPath.documentId())\n .limit(pageSize + 1)\n if (cursor) orgQuery = orgQuery.startAfter(firestore.collection('orgs').doc(cursor))\n const page = await orgQuery.get()\n const hasMore = page.docs.length > pageSize\n const orgDocs = hasMore ? page.docs.slice(0, pageSize) : page.docs\n\n const orgs: Record<string, OrgReport> = {}\n const failures: Record<string, string> = {}\n let due = 0\n let handled = 0\n let notified = 0\n let emailed = 0\n let deferred = false\n for (const orgDoc of orgDocs) {\n try {\n const report = await remindOrg(ctx, orgDoc)\n orgs[orgDoc.id] = report\n due += report.due\n handled += report.handled\n notified += report.notified\n emailed += report.emailed\n if (report.deferred) {\n deferred = true\n break\n }\n } catch (error) {\n console.error('[crm] task reminders failed for org', orgDoc.id, error)\n failures[orgDoc.id] = (error as Error)?.message ?? 'unknown'\n }\n }\n const failed = Object.keys(failures)\n return Response.json(\n {\n timeZone: ctx.timeZone,\n dryRun,\n swept: orgDocs.length,\n due,\n handled,\n notified,\n emailed,\n orgs,\n ...(deferred ? { deferred: true } : {}),\n ...(failed.length ? { failures } : {}),\n // A deferred run reports `done: true` — see the module comment —\n // so the workflow's loop does not re-POST into a full window.\n nextCursor: deferred || !hasMore ? null : (orgDocs[orgDocs.length - 1]?.id ?? null),\n done: deferred || !hasMore,\n },\n // 207: this chunk finished and some org in it needs a person, which is\n // what fails the workflow (its one alerting channel).\n { status: failed.length ? 207 : 200 },\n )\n } catch (error) {\n console.error('[crm] task reminders run failed', error)\n return Response.json({ error: 'Task reminders run failed' }, { status: 500 })\n }\n}\n"],"names":["buildRoute","CRM_COLLECTIONS","isReleaseFlagOnForOrg","notificationMuted","parseOrgReleaseFlagOverrides","pluginRequestFromWeb","resolveBrandingProfile","resolveEffectivePlan","resolveOrgEntitlements","Route","crmDigestEntitled","rateLimitedRetryAtMs","sendEmail","withoutMailWithheldAccounts","findUserByUidAcrossPools","firebaseAdmin","getServerReleaseFlagValues","listOrgMembers","memberHasOrgPermission","meterPlatformEmail","notifyUsers","filterSuppressedEmails","loadSystemEmail","renderLoadedSystemEmail","systemEmailBrand","systemEmailContent","isCronAuthorized","isCronDryRun","writeCronBeat","brandSupportLine","platformConsoleOrigin","consoleOrigin","digestTimeZone","composeCrmTaskReminderBody","composeCrmTaskReminderEmailText","composeCrmTaskReminderSubject","CRM_TASK_REMINDER_CEILING","crmTaskReminderDue","crmTaskReminderLink","recordCronBeat","jobId","app","firestore","CRM_TASK_REMINDERS_JOB_ID","CRM_TASK_REMINDERS_ORG_CHUNK","CRM_TASK_REMINDERS_MAX_UID_LOOKUPS","remindOrg","ctx","orgDoc","org","nowMs","timeZone","dryRun","orgId","id","data","quiet","due","handled","notified","emailed","features","skipped","flagOn","flagValues","page","ref","collection","tasks","where","orderBy","limit","get","doc","docs","push","title","String","kind","dueAtMs","remindAtMs","Number","assigneeUid","hostId","contactId","undefined","companyId","dealId","tasksTruncated","length","report","members","rows","settle","batch","task","update","reminderSentAtMs","commit","byAssignee","Map","unassigned","mine","set","size","listed","filter","member","orgSuspended","reachable","Set","map","$id","has","memberById","uids","keys","sort","userDocs","getAll","uid","userById","hostIds","Boolean","hostDocs","hostById","branding","orgSlug","workspaceName","trim","orgHub","origin","ORG_HOME","hubUrl","subdomain","HOST_DASHBOARD","host","taskUrl","hub","link","slice","indexOf","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","supportLine","brand","reminderEmail","poolLookups","row","userDoc","emailReason","address","email","toLowerCase","includes","pooled","catch","record","recipients","reminderInput","productName","subject","loaded","rendered","merge","omitClosing","options","result","to","text","fromName","context","priority","sent","deferred","reason","type","body","crmTaskRemindersRoute","request","method","headers","rawHeaders","query","Response","json","error","status","process","env","CRON_SECRET","orgDocs","Date","now","requestedLimit","pageSize","isFinite","Math","min","floor","rawCursor","cursor","orgQuery","FieldPath","documentId","startAfter","hasMore","orgs","failures","console","message","failed","Object","swept","nextCursor","done"],"mappings":"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"}
|
|
@@ -14,8 +14,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskListFields, crmTaskStatusWrite,
|
|
18
|
-
import {
|
|
17
|
+
*/ import { consentGroupForHost, CRM_COLLECTIONS, crmScopeTokens, crmTaskListFields, crmTaskStatusWrite, isOrgWideMember, memberCanSee } from "@aglyn/aglyn/server";
|
|
18
|
+
import { firebaseAdmin, getOrgForHost, memberHasOrgPermission, notifyUsers, resolveOrgMembership } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { emitHostEvent } from "@aglyn/tenant-runtime";
|
|
20
20
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
21
|
import { CRM_TASK_BATCH_MAX, readCrmTaskFields } from "../model/task-routes.js";
|
|
@@ -25,6 +25,8 @@ import { readCrmTaskPicklists } from "./read-picklist.js";
|
|
|
25
25
|
import { digestTimeZone } from "../model/crm-digest.js";
|
|
26
26
|
import { authorizeOrgCaller, readCrmRouteScope } from "./org-caller.js";
|
|
27
27
|
import { crmSuiteRefusal } from "./suite-gate.js";
|
|
28
|
+
import { crmTaskReminderAfterEdit, crmTaskReminderPending, crmTaskReminderWhen } from "../model/crm-task-reminders.js";
|
|
29
|
+
import { crmNextActivityLinksOf, recomputeCrmNextTaskAt } from "./crm-next-activity.js";
|
|
28
30
|
/** What the suite gate names when a task route refuses a plan without it. */ const TASKS_ACT = "Working a record's tasks";
|
|
29
31
|
export const refuse = (status, error)=>({
|
|
30
32
|
ok: false,
|