@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.
Files changed (52) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/crm-next-activity-column.js +1 -1
  3. package/src/lib/components/crm-next-activity-column.js.map +1 -1
  4. package/src/lib/components/reports/pipeline-card.js +2 -1
  5. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  6. package/src/lib/declarations.server.js +5 -0
  7. package/src/lib/declarations.server.js.map +1 -1
  8. package/src/lib/model/contact-record.js +2 -1
  9. package/src/lib/model/contact-record.js.map +1 -1
  10. package/src/lib/model/crm-next-activity.d.ts +107 -0
  11. package/src/lib/model/crm-next-activity.js +140 -0
  12. package/src/lib/model/crm-next-activity.js.map +1 -0
  13. package/src/lib/model/crm-recipes.js +33 -18
  14. package/src/lib/model/crm-recipes.js.map +1 -1
  15. package/src/lib/model/crm-task-reminders.d.ts +152 -0
  16. package/src/lib/model/crm-task-reminders.js +161 -0
  17. package/src/lib/model/crm-task-reminders.js.map +1 -0
  18. package/src/lib/model/next-activity.d.ts +1 -1
  19. package/src/lib/model/next-activity.js.map +1 -1
  20. package/src/lib/server/api-v1/crm-tasks.js +4 -2
  21. package/src/lib/server/api-v1/crm-tasks.js.map +1 -1
  22. package/src/lib/server/assign-contact-owner.d.ts +27 -1
  23. package/src/lib/server/assign-contact-owner.js +136 -30
  24. package/src/lib/server/assign-contact-owner.js.map +1 -1
  25. package/src/lib/server/automation-steps.d.ts +2 -1
  26. package/src/lib/server/automation-steps.js +210 -13
  27. package/src/lib/server/automation-steps.js.map +1 -1
  28. package/src/lib/server/capture-contact.d.ts +17 -0
  29. package/src/lib/server/capture-contact.js +31 -6
  30. package/src/lib/server/capture-contact.js.map +1 -1
  31. package/src/lib/server/crm-email-activity.d.ts +118 -0
  32. package/src/lib/server/crm-email-activity.js +148 -0
  33. package/src/lib/server/crm-email-activity.js.map +1 -0
  34. package/src/lib/server/crm-inbound-email.js +1 -1
  35. package/src/lib/server/crm-inbound-email.js.map +1 -1
  36. package/src/lib/server/crm-next-activity.d.ts +74 -0
  37. package/src/lib/server/crm-next-activity.js +194 -0
  38. package/src/lib/server/crm-next-activity.js.map +1 -0
  39. package/src/lib/server/email-send.js +2 -1
  40. package/src/lib/server/email-send.js.map +1 -1
  41. package/src/lib/server/merge-contacts.js +1 -1
  42. package/src/lib/server/merge-contacts.js.map +1 -1
  43. package/src/lib/server/next-activity-routes.js +2 -1
  44. package/src/lib/server/next-activity-routes.js.map +1 -1
  45. package/src/lib/server/recipe-routes.js +7 -1
  46. package/src/lib/server/recipe-routes.js.map +1 -1
  47. package/src/lib/server/record-timeline.js +26 -2
  48. package/src/lib/server/record-timeline.js.map +1 -1
  49. package/src/lib/server/task-reminders-route.js +2 -1
  50. package/src/lib/server/task-reminders-route.js.map +1 -1
  51. package/src/lib/server/task-routes.js +4 -2
  52. package/src/lib/server/task-routes.js.map +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-task-reminders.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 * A task's reminder at its own time (AGL-2659).\n *\n * The daily digest says once a morning what is late and what is due today;\n * nothing said \"the 3:00 call is at 3:00\". A task now carries `remindAtMs`\n * — the due time unless a person moved it, `null` for none — and an hourly\n * route reads every open task whose reminder has come due and tells the\n * assignee once, in the console and by mail. Every judgment here is pure so\n * the three writers of the field (the task routes, the REST resource, the\n * drawer) and the route that reads it agree on what a reminder is.\n *\n * ## Which reminder a save leaves\n *\n * A caller that SAYS what the reminder is gets what it said. A caller that\n * says nothing gets the rule: a new task's reminder is its due time, and an\n * edit that moves the due date moves a reminder that still sat on the old\n * due time — the reminder was never a choice of its own, so it follows —\n * while a reminder somebody set to a time of their own stays where they put\n * it. The one thing an edit never does is invent a reminder for a task that\n * has none.\n */\n\nimport type { CrmTask } from '@aglyn/aglyn/app-utils/crm'\n\n/**\n * The most due reminders one org's sweep reads per run.\n *\n * NEWEST FIRST, which is the whole reason a ceiling is safe here. A sent\n * reminder keeps its `remindAtMs` — the drawer shows when it fired — so the\n * range \"reminders up to now\" holds every reminder an open task ever had,\n * and a window of the OLDEST five hundred would fill with ones already sent\n * and never reach a new one. The newest five hundred always begin with what\n * fell due since the last run; the sent ones sit behind them.\n */\nexport const CRM_TASK_REMINDER_CEILING = 500\n\n/** The two fields the rule reads off a stored task, or off nothing on create. */\nexport interface CrmTaskReminderState {\n dueAtMs: number | null\n remindAtMs: number | null\n}\n\n/**\n * The reminder a save leaves on the task — see the module comment.\n *\n * `remindAtMs` is what the caller SAID: a time, `null` for \"no reminder\",\n * `undefined` for nothing said. `previous` is the stored task, or `null`\n * when the task is being created. A stored task from before reminders\n * existed carries no `remindAtMs` at all; it reads as `null` here — no\n * reminder — rather than as one that follows the due date, because a task\n * nobody ever set a reminder on should not start reminding the day its\n * title is corrected.\n */\nexport function crmTaskReminderAfterEdit(input: {\n dueAtMs: number | null\n remindAtMs?: number | null\n previous: CrmTaskReminderState | null\n}): number | null {\n const { dueAtMs, remindAtMs, previous } = input\n if (remindAtMs !== undefined) return remindAtMs\n if (!previous) return dueAtMs\n const followed =\n typeof previous.remindAtMs === 'number' && previous.remindAtMs === previous.dueAtMs\n if (followed && dueAtMs !== previous.dueAtMs) return dueAtMs\n return previous.remindAtMs ?? null\n}\n\n/** Whether a task's reminder is still owed: set, and not yet handled. */\nexport function crmTaskReminderPending(\n task: Pick<CrmTask, 'remindAtMs' | 'reminderSentAtMs'>,\n): boolean {\n return typeof task.remindAtMs === 'number' && task.reminderSentAtMs === undefined\n}\n\n/**\n * Whether the runner owes this task a reminder now: open, pending, and the\n * reminder's time has come. The query reads the first and third off the\n * index; the second is a fact about a field's absence, which a query cannot\n * select on, so the runner asks it here over the page it read.\n */\nexport function crmTaskReminderDue(\n task: Pick<CrmTask, 'status' | 'remindAtMs' | 'reminderSentAtMs'>,\n nowMs: number,\n): boolean {\n return (\n task.status === 'open' &&\n crmTaskReminderPending(task) &&\n (task.remindAtMs as number) <= nowMs\n )\n}\n\n/** A due reminder as the runner carries it: the fields a line and a link need. */\nexport interface CrmReminderTask\n extends Pick<CrmTask, 'title' | 'kind' | 'contactId' | 'companyId' | 'dealId'> {\n id: string\n dueAtMs: number | null\n remindAtMs: number\n assigneeUid: string\n /** The site the task was created on, or `''` for the organization's own. */\n hostId: string\n}\n\n/**\n * The console path a reminder opens: the record the task is for, in the\n * hub, or the tasks list when it is for nobody in particular — the same\n * page the assignment notification opens, in the `/{hostDocId}/rest` shape\n * every host notification uses and the `/org/rest` shape the console\n * rewrites onto the organization's own hub for a task with no site.\n */\nexport function crmTaskReminderLink(\n hostId: string | null,\n task: Pick<CrmTask, 'contactId' | 'companyId' | 'dealId'>,\n): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (task.contactId) return `${base}/contacts/${encodeURIComponent(task.contactId)}`\n if (task.dealId) return `${base}/deals/${encodeURIComponent(task.dealId)}`\n if (task.companyId) return `${base}/companies/${encodeURIComponent(task.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * `Wed, Sep 9, 3:00 PM CDT` — the words a reminder says a time with. The zone\n * is the server's choice, not the reader's, so it is printed: a reader an\n * hour away would otherwise take it for their own clock.\n */\nexport function crmTaskReminderWhen(ms: number, timeZone: string): string {\n return new Date(ms).toLocaleString('en-US', {\n timeZone,\n weekday: 'short',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n timeZoneName: 'short',\n })\n}\n\n/**\n * One task as the reminder email lists it: its title and when it is due, or\n * that it has no due date — a reminder can sit on a task with none, at a time\n * the person chose.\n */\nexport function crmTaskReminderLine(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n): string {\n return typeof task.dueAtMs === 'number'\n ? `${task.title} · due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}`\n : `${task.title} · no due date`\n}\n\n/**\n * The console notification's body, one per task, which is also what a\n * preview of it shows. It says it is a reminder and names the workspace, so\n * it reads whole without the \"Task reminder\" title above it.\n */\nexport function composeCrmTaskReminderBody(\n task: Pick<CrmReminderTask, 'title' | 'dueAtMs'>,\n timeZone: string,\n workspaceName?: string,\n): string {\n const workspace = workspaceName?.trim()\n const when =\n typeof task.dueAtMs === 'number'\n ? `, due ${crmTaskReminderWhen(task.dueAtMs, timeZone)}.`\n : ' (no due date).'\n return `Reminder for your task${workspace ? ` in ${workspace}` : ''}: ${task.title}${when}`\n}\n\n/**\n * One mail per member per run, so five tasks due at nine o'clock are one\n * message and not five. The subject names the task when there is one, and\n * never says a task is due: a reminder can be set ahead of the due time, or\n * on a task with none.\n */\nexport function composeCrmTaskReminderSubject(\n tasks: ReadonlyArray<Pick<CrmReminderTask, 'title'>>,\n): string {\n if (tasks.length === 1) return `Reminder: ${tasks[0].title}`\n return `Reminder: ${tasks.length} tasks`\n}\n\n/** What the reminder email needs from the route besides the tasks. */\nexport interface CrmTaskReminderEmailInput {\n tasks: readonly CrmReminderTask[]\n timeZone: string\n /** The product name the mail reads as — the org's brand when white-labeled. */\n productName: string\n /**\n * The workspace the tasks are in. The system email shows only the body, so\n * the body names it: a member of two workspaces gets reminders from each.\n */\n workspaceName?: string\n /** The console page a task opens on, absolute. */\n taskUrl: (task: CrmReminderTask) => string\n /** Account settings → Notifications, where the category can be muted. */\n settingsUrl: string\n /** The brand's support line, already prefixed, or empty. */\n supportLine?: string\n /**\n * Leave out the closing paragraph and the support line (AGL-3367), for the\n * copy that goes inside a system email: its footer says why the mail came\n * and where to get help, and its body links the settings page.\n */\n omitClosing?: boolean\n}\n\n/**\n * The plain-text email. Text rather than a designed template for the\n * reason the digest gives: the body is a list, and `sendEmail` synthesizes\n * the HTML part that makes its links live. Soonest due first, each task on\n * its own line with its link under it, and the way out last.\n */\nexport function composeCrmTaskReminderEmailText(input: CrmTaskReminderEmailInput): string {\n const { tasks, timeZone, productName } = input\n const ordered = [...tasks].sort(\n (a, b) => (a.dueAtMs ?? a.remindAtMs) - (b.dueAtMs ?? b.remindAtMs),\n )\n // \"About\", never \"is due\": a reminder fires at the time somebody chose,\n // which may be a day ahead of the due date or on a task with none, and\n // each line below says when its task is actually due.\n const where = input.workspaceName?.trim() ? ` in ${input.workspaceName.trim()}` : ''\n const lines: string[] = [\n ordered.length === 1\n ? `A reminder from ${productName} about your task${where}:`\n : `A reminder from ${productName} about ${ordered.length} of your tasks${where}:`,\n '',\n ]\n for (const task of ordered) {\n lines.push(`- ${crmTaskReminderLine(task, timeZone)}`)\n lines.push(` ${input.taskUrl(task)}`)\n }\n if (input.omitClosing) return lines.join('\\n').trimEnd()\n lines.push(\n '',\n 'You get task reminders because the Forms & bookings category is on in ' +\n `your notification settings: ${input.settingsUrl}`,\n )\n return lines.join('\\n') + (input.supportLine ?? '')\n}\n"],"names":["CRM_TASK_REMINDER_CEILING","crmTaskReminderAfterEdit","input","previous","dueAtMs","remindAtMs","undefined","followed","crmTaskReminderPending","task","reminderSentAtMs","crmTaskReminderDue","nowMs","status","crmTaskReminderLink","hostId","base","contactId","encodeURIComponent","dealId","companyId","crmTaskReminderWhen","ms","timeZone","Date","toLocaleString","weekday","month","day","hour","minute","timeZoneName","crmTaskReminderLine","title","composeCrmTaskReminderBody","workspaceName","workspace","trim","when","composeCrmTaskReminderSubject","tasks","length","composeCrmTaskReminderEmailText","productName","ordered","sort","a","b","where","lines","push","taskUrl","omitClosing","join","trimEnd","settingsUrl","supportLine"],"mappings":"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"}
@@ -14,8 +14,8 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import type { CrmNextActivityLink } from '@aglyn/aglyn';
18
17
  import type { CrmTaskRouteScope } from './task-routes';
18
+ import { type CrmNextActivityLink } from './crm-next-activity';
19
19
  /**
20
20
  * The contract between the console and the plugin's next-activity route
21
21
  * (AGL-2661), the door a CLIENT-DIRECT task write uses to keep the records'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/next-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { CrmNextActivityLink } from '@aglyn/aglyn'\nimport type { CrmTaskRouteScope } from './task-routes'\n\n/**\n * The contract between the console and the plugin's next-activity route\n * (AGL-2661), the door a CLIENT-DIRECT task write uses to keep the records'\n * `nextTaskAtMs` honest.\n *\n * The two task routes recompute it themselves. Every other task write —\n * reopening, snoozing, deleting, the bulk bar's reassign — is a Firestore\n * write from the browser, and a browser cannot recompute a denormalized\n * figure it is not allowed to write consistently. So after such a write the\n * surface tells this route which records the task named, and the route\n * reads the tasks back and stores the answer. Best effort by design: the\n * task write already happened, and a figure that did not move is what the\n * Fields section's recompute exists for. The browser's side is\n * `next-activity-api.ts`; this module is the contract both halves import.\n *\n * `all: true` is that recompute — the whole organization, every open task\n * once — and is the Fields section's maintenance action.\n */\nexport const CRM_NEXT_ACTIVITY_ROUTE = 'crm/next-activity' as const\n\nexport const crmNextActivityRouteUrl = (): string => `/api/${CRM_NEXT_ACTIVITY_ROUTE}`\n\n/** The most records one request recomputes — the bulk bar's own window. */\nexport const CRM_NEXT_ACTIVITY_LINKS_MAX = 200\n\nexport type CrmNextActivityRequest = CrmTaskRouteScope &\n ({ links: CrmNextActivityLink[]; all?: false } | { all: true; links?: undefined })\n\nexport interface CrmNextActivityResponse {\n ok: true\n /** Records written. */\n records: number\n /** Records a task named that no longer exist. */\n missing: number\n /** The whole-org form only: how many open tasks it read, and whether it read them all. */\n tasks?: number\n cleared?: number\n truncated?: boolean\n}\n\n/** The links a task row carries, as the request names them. */\nexport function nextActivityRequestLinks(\n tasks: readonly { contactId?: string | null; companyId?: string | null; dealId?: string | null }[],\n): CrmNextActivityLink[] {\n return tasks.map((task) => ({\n ...(task.contactId ? { contactId: task.contactId } : {}),\n ...(task.companyId ? { companyId: task.companyId } : {}),\n ...(task.dealId ? { dealId: task.dealId } : {}),\n }))\n}\n"],"names":["CRM_NEXT_ACTIVITY_ROUTE","crmNextActivityRouteUrl","CRM_NEXT_ACTIVITY_LINKS_MAX","nextActivityRequestLinks","tasks","map","task","contactId","companyId","dealId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAKD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMA,0BAA0B,oBAA4B;AAEnE,OAAO,MAAMC,0BAA0B,IAAc,CAAC,KAAK,EAAED,yBAAyB,CAAA;AAEtF,yEAAyE,GACzE,OAAO,MAAME,8BAA8B,IAAG;AAiB9C,6DAA6D,GAC7D,OAAO,SAASC,yBACdC,KAAkG;IAElG,OAAOA,MAAMC,GAAG,CAAC,CAACC,OAAU,aACtBA,KAAKC,SAAS,GAAG;YAAEA,WAAWD,KAAKC,SAAS;QAAC,IAAI,CAAC,GAClDD,KAAKE,SAAS,GAAG;YAAEA,WAAWF,KAAKE,SAAS;QAAC,IAAI,CAAC,GAClDF,KAAKG,MAAM,GAAG;YAAEA,QAAQH,KAAKG,MAAM;QAAC,IAAI,CAAC;AAEjD"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/next-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { CrmTaskRouteScope } from './task-routes'\nimport { type CrmNextActivityLink } from './crm-next-activity'\n\n/**\n * The contract between the console and the plugin's next-activity route\n * (AGL-2661), the door a CLIENT-DIRECT task write uses to keep the records'\n * `nextTaskAtMs` honest.\n *\n * The two task routes recompute it themselves. Every other task write —\n * reopening, snoozing, deleting, the bulk bar's reassign — is a Firestore\n * write from the browser, and a browser cannot recompute a denormalized\n * figure it is not allowed to write consistently. So after such a write the\n * surface tells this route which records the task named, and the route\n * reads the tasks back and stores the answer. Best effort by design: the\n * task write already happened, and a figure that did not move is what the\n * Fields section's recompute exists for. The browser's side is\n * `next-activity-api.ts`; this module is the contract both halves import.\n *\n * `all: true` is that recompute — the whole organization, every open task\n * once — and is the Fields section's maintenance action.\n */\nexport const CRM_NEXT_ACTIVITY_ROUTE = 'crm/next-activity' as const\n\nexport const crmNextActivityRouteUrl = (): string => `/api/${CRM_NEXT_ACTIVITY_ROUTE}`\n\n/** The most records one request recomputes — the bulk bar's own window. */\nexport const CRM_NEXT_ACTIVITY_LINKS_MAX = 200\n\nexport type CrmNextActivityRequest = CrmTaskRouteScope &\n ({ links: CrmNextActivityLink[]; all?: false } | { all: true; links?: undefined })\n\nexport interface CrmNextActivityResponse {\n ok: true\n /** Records written. */\n records: number\n /** Records a task named that no longer exist. */\n missing: number\n /** The whole-org form only: how many open tasks it read, and whether it read them all. */\n tasks?: number\n cleared?: number\n truncated?: boolean\n}\n\n/** The links a task row carries, as the request names them. */\nexport function nextActivityRequestLinks(\n tasks: readonly { contactId?: string | null; companyId?: string | null; dealId?: string | null }[],\n): CrmNextActivityLink[] {\n return tasks.map((task) => ({\n ...(task.contactId ? { contactId: task.contactId } : {}),\n ...(task.companyId ? { companyId: task.companyId } : {}),\n ...(task.dealId ? { dealId: task.dealId } : {}),\n }))\n}\n"],"names":["CRM_NEXT_ACTIVITY_ROUTE","crmNextActivityRouteUrl","CRM_NEXT_ACTIVITY_LINKS_MAX","nextActivityRequestLinks","tasks","map","task","contactId","companyId","dealId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAKD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMA,0BAA0B,oBAA4B;AAEnE,OAAO,MAAMC,0BAA0B,IAAc,CAAC,KAAK,EAAED,yBAAyB,CAAA;AAEtF,yEAAyE,GACzE,OAAO,MAAME,8BAA8B,IAAG;AAiB9C,6DAA6D,GAC7D,OAAO,SAASC,yBACdC,KAAkG;IAElG,OAAOA,MAAMC,GAAG,CAAC,CAACC,OAAU,aACtBA,KAAKC,SAAS,GAAG;YAAEA,WAAWD,KAAKC,SAAS;QAAC,IAAI,CAAC,GAClDD,KAAKE,SAAS,GAAG;YAAEA,WAAWF,KAAKE,SAAS;QAAC,IAAI,CAAC,GAClDF,KAAKG,MAAM,GAAG;YAAEA,QAAQH,KAAKG,MAAM;QAAC,IAAI,CAAC;AAEjD"}
@@ -36,12 +36,14 @@ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_wi
36
36
  * meaning behind it, `open`; the task stores both, and answers the meaning
37
37
  * under the field's own name with the label beside it as `typeLabel`,
38
38
  * `priorityLabel` and `statusLabel`.
39
- */ import { CRM_COLLECTIONS, CRM_TASK_PICKLIST_IDS, crmPicklistLabelForNew, crmTaskPicklistLabels, crmTaskReminderAfterEdit, crmTaskReminderPending, createResourceUid, crmTaskListFields, resolveCrmSemanticPicklistWrite } from "@aglyn/aglyn/server";
40
- import { apiJson, ApiErrors, crmNextActivityLinksOf, recomputeCrmNextTaskAt, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
39
+ */ import { CRM_COLLECTIONS, CRM_TASK_PICKLIST_IDS, crmPicklistLabelForNew, crmTaskPicklistLabels, createResourceUid, crmTaskListFields, resolveCrmSemanticPicklistWrite } from "@aglyn/aglyn/server";
40
+ import { apiJson, ApiErrors, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
41
41
  import { FieldValue, Timestamp } from "firebase-admin/firestore";
42
42
  import { claimWrite, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
43
43
  import { CRM_TEXT_MAX, CRM_TITLE_MAX, createPayload, crmCollection, crmCreateStamp, crmRefErrors, crmTimes, crmValidationFailed, isoFromMs, listCrm, memberError, parseIsoInstant, readCrmSite, readEqualityFilters, readOptionalText, readRefId, refuseUnknownKeys, updatePayload } from "./crm-shared.js";
44
44
  import { readCrmTaskPicklists } from "../read-picklist.js";
45
+ import { crmTaskReminderAfterEdit, crmTaskReminderPending } from "../../model/crm-task-reminders.js";
46
+ import { crmNextActivityLinksOf, recomputeCrmNextTaskAt } from "../crm-next-activity.js";
45
47
  const TASK_STATUSES = [
46
48
  'open',
47
49
  'done'
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-tasks.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 * `/v1/tasks` (AGL-2606) — what the team owes a person: a call, an email, a\n * meeting or a to-do, with a due date, pointing at the contact, company or\n * deal it is for. A task may point at nothing — a plain to-do is still a\n * task — which is the one place it differs from an activity.\n *\n * `status` is the only state machine here and it has two states. Marking a\n * task `done` stamps `completedAtMs`; marking it `open` again clears it, so\n * a reopened task never reads as completed on the date it was first closed.\n *\n * `remindAt` (AGL-2659) is the task's reminder: the due time unless the\n * body names a time or `null`, carried along when a moved due date leaves\n * it behind, and cleared by completion while it is still owed — the rule\n * in `crmTaskReminderAfterEdit`, the same one the console's drawer keeps.\n * `reminderSentAt` is read-only: when the hourly runner handled it.\n *\n * `kind`, `priority` and `status` (AGL-3517) take a value of the org's\n * Type, Priority or Status picklist — its label, \"In Progress\" — or the\n * meaning behind it, `open`; the task stores both, and answers the meaning\n * under the field's own name with the label beside it as `typeLabel`,\n * `priorityLabel` and `statusLabel`.\n */\nimport {\n CRM_COLLECTIONS,\n CRM_TASK_PICKLIST_IDS,\n type CrmPicklistId,\n crmPicklistLabelForNew,\n type CrmTask,\n type CrmTaskKind,\n type CrmTaskPicklists,\n crmTaskPicklistLabels,\n type CrmTaskPriority,\n crmTaskReminderAfterEdit,\n crmTaskReminderPending,\n type CrmTaskStatus,\n createResourceUid,\n crmTaskListFields,\n resolveCrmSemanticPicklistWrite,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n crmNextActivityLinksOf,\n recomputeCrmNextTaskAt,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n type Clearable,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n updatePayload,\n} from './crm-shared'\nimport { readCrmTaskPicklists } from '../read-picklist'\n\nconst TASK_STATUSES = ['open', 'done'] as const\n\n/** The org's task picklists, read once for a request. */\nconst taskPicklistsOf = (ctx: ApiV1Context): Promise<CrmTaskPicklists> =>\n readCrmTaskPicklists(ctx.firestore, ctx.orgId)\n\n/** The task object as published. Every writable field appears here. */\nfunction taskView(doc: FirebaseFirestore.DocumentSnapshot, picklists: CrmTaskPicklists) {\n const data = (doc.data() ?? {}) as Partial<CrmTask>\n const labels = crmTaskPicklistLabels(data, picklists)\n return {\n id: doc.id,\n object: 'task',\n title: data.title ?? null,\n notes: data.notes ?? null,\n kind: data.kind ?? 'todo',\n typeLabel: labels.type,\n priority: data.priority ?? 'normal',\n priorityLabel: labels.priority,\n status: data.status ?? 'open',\n statusLabel: labels.status,\n dueAt: isoFromMs(data.dueAtMs),\n remindAt: isoFromMs(data.remindAtMs),\n reminderSentAt: isoFromMs(data.reminderSentAtMs),\n completedAt: isoFromMs(data.completedAtMs),\n assigneeUid: data.assigneeUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst TASK_WRITABLE = new Set([\n 'title',\n 'notes',\n 'kind',\n 'priority',\n 'status',\n 'dueAt',\n 'remindAt',\n 'assigneeUid',\n 'contactId',\n 'companyId',\n 'dealId',\n])\n\n/** A picklist field as resolved: the meaning stored under its name, the label beside it. */\ninterface Picked<T extends string> {\n meaning: T\n label: string | null\n}\n\ninterface TaskInput {\n title?: string\n notes?: Clearable<string>\n kind?: Picked<CrmTaskKind>\n priority?: Picked<CrmTaskPriority>\n status?: Picked<CrmTaskStatus>\n dueAtMs?: Clearable<number>\n remindAtMs?: Clearable<number>\n assigneeUid?: Clearable<string>\n contactId?: Clearable<string>\n companyId?: Clearable<string>\n dealId?: Clearable<string>\n}\n\n/**\n * A Type, Priority or Status as the body names it — a label or a meaning —\n * against the org's list; the stored label is kept when the body names it\n * again. Absent answers `undefined`; anything the list does not hold is an\n * error naming the values it does.\n */\nfunction readPicklistField<T extends string>(\n body: Record<string, unknown>,\n key: 'kind' | 'priority' | 'status',\n id: CrmPicklistId,\n picklists: CrmTaskPicklists,\n stored: string | null | undefined,\n errors: Record<string, string>,\n): Picked<T> | undefined {\n const value = body[key]\n if (value === undefined) return undefined\n const list =\n key === 'kind' ? picklists.type : key === 'priority' ? picklists.priority : picklists.status\n const resolved =\n typeof value === 'string' ? resolveCrmSemanticPicklistWrite(id, list, value, stored) : null\n if (resolved?.ok) return { meaning: resolved.meaning as T, label: resolved.label }\n errors[key] =\n resolved?.ok === false ? resolved.error : 'Must be a value of the list, or its meaning'\n return undefined\n}\n\nfunction readTaskInput(\n body: Record<string, unknown>,\n { partial }: { partial: boolean },\n picklists: CrmTaskPicklists,\n stored: Partial<CrmTask> | null = null,\n): { values: TaskInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n const values: TaskInput = {}\n const allowed = new Set(TASK_WRITABLE)\n if (!partial) allowed.add('consentSiteId')\n refuseUnknownKeys(body, allowed, 'task', errors)\n\n if (body.title !== undefined || !partial) {\n const title = String(body.title ?? '')\n .trim()\n .slice(0, CRM_TITLE_MAX)\n if (title) values.title = title\n else errors.title = partial ? 'Must not be empty' : 'A title is required'\n }\n\n const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors)\n if (notes !== undefined) values.notes = notes\n const kind = readPicklistField<CrmTaskKind>(\n body, 'kind', CRM_TASK_PICKLIST_IDS.type, picklists, stored?.typeLabel, errors,\n )\n if (kind) values.kind = kind\n const priority = readPicklistField<CrmTaskPriority>(\n body, 'priority', CRM_TASK_PICKLIST_IDS.priority, picklists, stored?.priorityLabel, errors,\n )\n if (priority) values.priority = priority\n const status = readPicklistField<CrmTaskStatus>(\n body, 'status', CRM_TASK_PICKLIST_IDS.status, picklists, stored?.statusLabel, errors,\n )\n if (status) values.status = status\n\n if (body.dueAt !== undefined) {\n if (body.dueAt === null) {\n values.dueAtMs = null\n } else {\n const ms = parseIsoInstant(body.dueAt)\n if (ms === null) {\n errors.dueAt = 'Must be an ISO 8601 instant, like 2026-09-10T15:00:00Z'\n } else {\n values.dueAtMs = ms\n }\n }\n }\n\n if (body.remindAt !== undefined) {\n if (body.remindAt === null) {\n values.remindAtMs = null\n } else {\n const ms = parseIsoInstant(body.remindAt)\n if (ms === null) {\n errors.remindAt = 'Must be an ISO 8601 instant, like 2026-09-10T14:00:00Z'\n } else {\n values.remindAtMs = ms\n }\n }\n }\n\n const assigneeUid = readOptionalText(body, 'assigneeUid', CRM_TITLE_MAX, errors)\n if (assigneeUid !== undefined) values.assigneeUid = assigneeUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id !== undefined) values[field] = id\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\nasync function taskRefErrors(\n ctx: ApiV1Context,\n values: TaskInput,\n): Promise<Record<string, string>> {\n const [assignee, refs] = await Promise.all([\n memberError(ctx, 'assigneeUid', values.assigneeUid),\n crmRefErrors(ctx, {\n contactId: values.contactId ?? undefined,\n companyId: values.companyId ?? undefined,\n dealId: values.dealId ?? undefined,\n }),\n ])\n return { ...assignee, ...refs }\n}\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here from BOTH sides of the change. Its own catch: the\n * task write is done, and a figure that did not move is what the Fields\n * section's recompute is for, not a failed request.\n */\nasync function settleNextActivity(\n ctx: ApiV1Context,\n links: readonly (Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n ctx.firestore,\n ctx.orgId,\n links.map((link) => crmNextActivityLinksOf(link)),\n )\n } catch (error) {\n console.error('[api-v1] next activity could not be recomputed', ctx.orgId, error)\n }\n}\n\n/** `POST /v1/tasks`. */\nasync function createTask(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const picklists = await taskPicklistsOf(ctx)\n const parsed = readTaskInput(body, { partial: false }, picklists)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'task', parsed.errors)\n const site = readCrmSite(ctx, 'task', body)\n if ('response' in site) return site.response\n const refErrors = await taskRefErrors(ctx, parsed.values)\n if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'task', refErrors)\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.tasks)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'tasks',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { title, kind: pickedKind, priority: pickedPriority, status: pickedStatus, remindAtMs, ...rest } =\n parsed.values\n const kind = pickedKind?.meaning ?? 'todo'\n const priority = pickedPriority?.meaning ?? 'normal'\n const status = pickedStatus?.meaning ?? 'open'\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n const record: Record<string, unknown> = {\n title,\n // Each meaning with the org's label beside it (AGL-3517).\n kind,\n typeLabel: pickedKind ? pickedKind.label : crmPicklistLabelForNew(picklists.type, kind),\n priority,\n priorityLabel: pickedPriority\n ? pickedPriority.label\n : crmPicklistLabelForNew(picklists.priority, priority),\n status,\n statusLabel: pickedStatus\n ? pickedStatus.label\n : crmPicklistLabelForNew(picklists.status, status),\n // A task created done was completed the instant it was created — the\n // same instant its `createdAt` carries.\n ...(status === 'done' ? { completedAtMs: stamp.createdAt.toMillis() } : {}),\n ...createPayload({\n ...rest,\n // A reminder on a task created done is owed to nobody.\n remindAtMs:\n status === 'done'\n ? null\n : crmTaskReminderAfterEdit({\n dueAtMs: rest.dueAtMs ?? null,\n remindAtMs,\n previous: null,\n }),\n }),\n /*\n * Stored as `null` when the body names none, never left absent: every\n * tasks list orders by `dueAtMs`, and Firestore leaves a document\n * without the ordered field out of the answer (AGL-3321).\n */\n dueAtMs: rest.dueAtMs ?? null,\n ...stamp,\n }\n // What the console's Tasks list searches by (AGL-3321).\n await collection.doc(id).create({ ...record, ...crmTaskListFields(record) })\n await settleNextActivity(ctx, [rest as Record<string, unknown>])\n const view = taskView(await collection.doc(id).get(), picklists)\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `PATCH /v1/tasks/{id}`. */\nasync function updateTask(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const body = await readJsonBody(request)\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n const picklists = await taskPicklistsOf(ctx)\n const parsed = readTaskInput(body, { partial: true }, picklists, snap.data() as Partial<CrmTask>)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'task', parsed.errors)\n const refErrors = await taskRefErrors(ctx, parsed.values)\n if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'task', refErrors)\n\n const { status: pickedStatus, kind: pickedKind, priority: pickedPriority, remindAtMs, ...rest } =\n parsed.values\n const update: Record<string, unknown> = updatePayload(rest)\n // Each meaning with the org's label beside it (AGL-3517).\n if (pickedKind) {\n update.kind = pickedKind.meaning\n update.typeLabel = pickedKind.label\n }\n if (pickedPriority) {\n update.priority = pickedPriority.meaning\n update.priorityLabel = pickedPriority.label\n }\n // A cleared due date is stored `null`, not deleted — see the create.\n if (rest.dueAtMs === null) update.dueAtMs = null\n // One instant for the write: a task completed at T reads updated at T.\n const now = Timestamp.now()\n const status = pickedStatus?.meaning\n const completing = status === 'done' && status !== snap.get('status')\n if (pickedStatus) update.statusLabel = pickedStatus.label\n if (status !== undefined && status !== snap.get('status')) {\n update.status = status\n update.completedAtMs = status === 'done' ? now.toMillis() : null\n }\n /*\n * The reminder the edit leaves: what the body said, else the rule over\n * the stored task and the due date this PATCH moves it to. Completion\n * clears one still owed. A reminder that MOVED — to a time or to none —\n * is one not yet sent, so the runner's mark comes off with it; one that\n * stayed keeps its mark.\n */\n const stored = snap.data() as Partial<CrmTask>\n const previous = {\n dueAtMs: typeof stored.dueAtMs === 'number' ? stored.dueAtMs : null,\n remindAtMs: typeof stored.remindAtMs === 'number' ? stored.remindAtMs : null,\n }\n const nextRemindAtMs =\n completing && crmTaskReminderPending(stored)\n ? null\n : crmTaskReminderAfterEdit({\n dueAtMs: rest.dueAtMs === undefined ? previous.dueAtMs : rest.dueAtMs,\n remindAtMs,\n previous,\n })\n if (nextRemindAtMs !== previous.remindAtMs) {\n update.remindAtMs = nextRemindAtMs === null ? FieldValue.delete() : nextRemindAtMs\n update.reminderSentAtMs = FieldValue.delete()\n }\n if (Object.keys(update).length > 0) {\n await ref.update({ ...update, updatedAt: now })\n await settleNextActivity(ctx, [snap.data(), rest as Record<string, unknown>])\n // What the console's Tasks list searches (AGL-3321).\n if ('title' in update) await restampCrmListFieldsAt(ref, 'crmTasks')\n }\n return apiJson(taskView(await ref.get(), picklists), { headers: ctx.headers })\n}\n\n/** `DELETE /v1/tasks/{id}`. */\nasync function deleteTask(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'task-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n // Read before the delete: the records it named are recomputed without it.\n const named = snap.data()\n await ref.delete()\n await settleNextActivity(ctx, [named])\n const view = { id: ref.id, object: 'task', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/tasks` filters, most selective first. */\nasync function listTasks(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawStatus = url.searchParams.get('status')\n if (rawStatus !== null && rawStatus.trim() !== '' && !(TASK_STATUSES as readonly string[]).includes(rawStatus.trim())) {\n return crmValidationFailed(ctx, 'task filter', {\n status: `Must be one of: ${TASK_STATUSES.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, [\n 'dealId',\n 'contactId',\n 'companyId',\n 'assigneeUid',\n 'status',\n ])\n const picklists = await taskPicklistsOf(ctx)\n return listCrm(ctx, collection, url, filters, (doc) => taskView(doc, picklists))\n}\n\nexport async function handleTasks(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.tasks)\n const [, taskId] = segments\n\n if (!taskId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listTasks(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createTask(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(taskId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n return apiJson(taskView(snap, await taskPicklistsOf(ctx)), { headers: ctx.headers })\n }\n if (request.method === 'PATCH') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return updateTask(request, ctx, ref)\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteTask(request, ctx, ref)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, PATCH, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_TASK_PICKLIST_IDS","crmPicklistLabelForNew","crmTaskPicklistLabels","crmTaskReminderAfterEdit","crmTaskReminderPending","createResourceUid","crmTaskListFields","resolveCrmSemanticPicklistWrite","apiJson","ApiErrors","crmNextActivityLinksOf","recomputeCrmNextTaskAt","restampCrmListFieldsAt","FieldValue","Timestamp","claimWrite","readJsonBody","requireScope","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","updatePayload","readCrmTaskPicklists","TASK_STATUSES","taskPicklistsOf","ctx","firestore","orgId","taskView","doc","picklists","data","labels","id","object","title","notes","kind","typeLabel","type","priority","priorityLabel","status","statusLabel","dueAt","dueAtMs","remindAt","remindAtMs","reminderSentAt","reminderSentAtMs","completedAt","completedAtMs","assigneeUid","contactId","companyId","dealId","siteId","hostId","TASK_WRITABLE","Set","readPicklistField","body","key","stored","errors","value","undefined","list","resolved","ok","meaning","label","error","readTaskInput","partial","values","allowed","add","String","trim","slice","ms","field","Object","keys","length","taskRefErrors","assignee","refs","Promise","all","settleNextActivity","links","map","link","console","createTask","request","parsed","site","response","refErrors","collection","tasks","claimed","headers","get","replay","claim","rest","pickedKind","pickedPriority","pickedStatus","stamp","record","createdAt","toMillis","previous","create","view","release","updateTask","ref","snap","exists","notFound","message","update","now","completing","nextRemindAtMs","delete","updatedAt","deleteTask","named","deleted","listTasks","url","rawStatus","searchParams","includes","join","filters","handleTasks","segments","taskId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-tasks.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 * `/v1/tasks` (AGL-2606) — what the team owes a person: a call, an email, a\n * meeting or a to-do, with a due date, pointing at the contact, company or\n * deal it is for. A task may point at nothing — a plain to-do is still a\n * task — which is the one place it differs from an activity.\n *\n * `status` is the only state machine here and it has two states. Marking a\n * task `done` stamps `completedAtMs`; marking it `open` again clears it, so\n * a reopened task never reads as completed on the date it was first closed.\n *\n * `remindAt` (AGL-2659) is the task's reminder: the due time unless the\n * body names a time or `null`, carried along when a moved due date leaves\n * it behind, and cleared by completion while it is still owed — the rule\n * in `crmTaskReminderAfterEdit`, the same one the console's drawer keeps.\n * `reminderSentAt` is read-only: when the hourly runner handled it.\n *\n * `kind`, `priority` and `status` (AGL-3517) take a value of the org's\n * Type, Priority or Status picklist — its label, \"In Progress\" — or the\n * meaning behind it, `open`; the task stores both, and answers the meaning\n * under the field's own name with the label beside it as `typeLabel`,\n * `priorityLabel` and `statusLabel`.\n */\nimport {\n CRM_COLLECTIONS,\n CRM_TASK_PICKLIST_IDS,\n type CrmPicklistId,\n crmPicklistLabelForNew,\n type CrmTask,\n type CrmTaskKind,\n type CrmTaskPicklists,\n crmTaskPicklistLabels,\n type CrmTaskPriority,\n type CrmTaskStatus,\n createResourceUid,\n crmTaskListFields,\n resolveCrmSemanticPicklistWrite,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n type Clearable,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n updatePayload,\n} from './crm-shared'\nimport { readCrmTaskPicklists } from '../read-picklist'\nimport { crmTaskReminderAfterEdit, crmTaskReminderPending } from '../../model/crm-task-reminders'\nimport { crmNextActivityLinksOf, recomputeCrmNextTaskAt } from '../crm-next-activity'\n\nconst TASK_STATUSES = ['open', 'done'] as const\n\n/** The org's task picklists, read once for a request. */\nconst taskPicklistsOf = (ctx: ApiV1Context): Promise<CrmTaskPicklists> =>\n readCrmTaskPicklists(ctx.firestore, ctx.orgId)\n\n/** The task object as published. Every writable field appears here. */\nfunction taskView(doc: FirebaseFirestore.DocumentSnapshot, picklists: CrmTaskPicklists) {\n const data = (doc.data() ?? {}) as Partial<CrmTask>\n const labels = crmTaskPicklistLabels(data, picklists)\n return {\n id: doc.id,\n object: 'task',\n title: data.title ?? null,\n notes: data.notes ?? null,\n kind: data.kind ?? 'todo',\n typeLabel: labels.type,\n priority: data.priority ?? 'normal',\n priorityLabel: labels.priority,\n status: data.status ?? 'open',\n statusLabel: labels.status,\n dueAt: isoFromMs(data.dueAtMs),\n remindAt: isoFromMs(data.remindAtMs),\n reminderSentAt: isoFromMs(data.reminderSentAtMs),\n completedAt: isoFromMs(data.completedAtMs),\n assigneeUid: data.assigneeUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst TASK_WRITABLE = new Set([\n 'title',\n 'notes',\n 'kind',\n 'priority',\n 'status',\n 'dueAt',\n 'remindAt',\n 'assigneeUid',\n 'contactId',\n 'companyId',\n 'dealId',\n])\n\n/** A picklist field as resolved: the meaning stored under its name, the label beside it. */\ninterface Picked<T extends string> {\n meaning: T\n label: string | null\n}\n\ninterface TaskInput {\n title?: string\n notes?: Clearable<string>\n kind?: Picked<CrmTaskKind>\n priority?: Picked<CrmTaskPriority>\n status?: Picked<CrmTaskStatus>\n dueAtMs?: Clearable<number>\n remindAtMs?: Clearable<number>\n assigneeUid?: Clearable<string>\n contactId?: Clearable<string>\n companyId?: Clearable<string>\n dealId?: Clearable<string>\n}\n\n/**\n * A Type, Priority or Status as the body names it — a label or a meaning —\n * against the org's list; the stored label is kept when the body names it\n * again. Absent answers `undefined`; anything the list does not hold is an\n * error naming the values it does.\n */\nfunction readPicklistField<T extends string>(\n body: Record<string, unknown>,\n key: 'kind' | 'priority' | 'status',\n id: CrmPicklistId,\n picklists: CrmTaskPicklists,\n stored: string | null | undefined,\n errors: Record<string, string>,\n): Picked<T> | undefined {\n const value = body[key]\n if (value === undefined) return undefined\n const list =\n key === 'kind' ? picklists.type : key === 'priority' ? picklists.priority : picklists.status\n const resolved =\n typeof value === 'string' ? resolveCrmSemanticPicklistWrite(id, list, value, stored) : null\n if (resolved?.ok) return { meaning: resolved.meaning as T, label: resolved.label }\n errors[key] =\n resolved?.ok === false ? resolved.error : 'Must be a value of the list, or its meaning'\n return undefined\n}\n\nfunction readTaskInput(\n body: Record<string, unknown>,\n { partial }: { partial: boolean },\n picklists: CrmTaskPicklists,\n stored: Partial<CrmTask> | null = null,\n): { values: TaskInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n const values: TaskInput = {}\n const allowed = new Set(TASK_WRITABLE)\n if (!partial) allowed.add('consentSiteId')\n refuseUnknownKeys(body, allowed, 'task', errors)\n\n if (body.title !== undefined || !partial) {\n const title = String(body.title ?? '')\n .trim()\n .slice(0, CRM_TITLE_MAX)\n if (title) values.title = title\n else errors.title = partial ? 'Must not be empty' : 'A title is required'\n }\n\n const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors)\n if (notes !== undefined) values.notes = notes\n const kind = readPicklistField<CrmTaskKind>(\n body, 'kind', CRM_TASK_PICKLIST_IDS.type, picklists, stored?.typeLabel, errors,\n )\n if (kind) values.kind = kind\n const priority = readPicklistField<CrmTaskPriority>(\n body, 'priority', CRM_TASK_PICKLIST_IDS.priority, picklists, stored?.priorityLabel, errors,\n )\n if (priority) values.priority = priority\n const status = readPicklistField<CrmTaskStatus>(\n body, 'status', CRM_TASK_PICKLIST_IDS.status, picklists, stored?.statusLabel, errors,\n )\n if (status) values.status = status\n\n if (body.dueAt !== undefined) {\n if (body.dueAt === null) {\n values.dueAtMs = null\n } else {\n const ms = parseIsoInstant(body.dueAt)\n if (ms === null) {\n errors.dueAt = 'Must be an ISO 8601 instant, like 2026-09-10T15:00:00Z'\n } else {\n values.dueAtMs = ms\n }\n }\n }\n\n if (body.remindAt !== undefined) {\n if (body.remindAt === null) {\n values.remindAtMs = null\n } else {\n const ms = parseIsoInstant(body.remindAt)\n if (ms === null) {\n errors.remindAt = 'Must be an ISO 8601 instant, like 2026-09-10T14:00:00Z'\n } else {\n values.remindAtMs = ms\n }\n }\n }\n\n const assigneeUid = readOptionalText(body, 'assigneeUid', CRM_TITLE_MAX, errors)\n if (assigneeUid !== undefined) values.assigneeUid = assigneeUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id !== undefined) values[field] = id\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\nasync function taskRefErrors(\n ctx: ApiV1Context,\n values: TaskInput,\n): Promise<Record<string, string>> {\n const [assignee, refs] = await Promise.all([\n memberError(ctx, 'assigneeUid', values.assigneeUid),\n crmRefErrors(ctx, {\n contactId: values.contactId ?? undefined,\n companyId: values.companyId ?? undefined,\n dealId: values.dealId ?? undefined,\n }),\n ])\n return { ...assignee, ...refs }\n}\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here from BOTH sides of the change. Its own catch: the\n * task write is done, and a figure that did not move is what the Fields\n * section's recompute is for, not a failed request.\n */\nasync function settleNextActivity(\n ctx: ApiV1Context,\n links: readonly (Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n ctx.firestore,\n ctx.orgId,\n links.map((link) => crmNextActivityLinksOf(link)),\n )\n } catch (error) {\n console.error('[api-v1] next activity could not be recomputed', ctx.orgId, error)\n }\n}\n\n/** `POST /v1/tasks`. */\nasync function createTask(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const picklists = await taskPicklistsOf(ctx)\n const parsed = readTaskInput(body, { partial: false }, picklists)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'task', parsed.errors)\n const site = readCrmSite(ctx, 'task', body)\n if ('response' in site) return site.response\n const refErrors = await taskRefErrors(ctx, parsed.values)\n if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'task', refErrors)\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.tasks)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'tasks',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { title, kind: pickedKind, priority: pickedPriority, status: pickedStatus, remindAtMs, ...rest } =\n parsed.values\n const kind = pickedKind?.meaning ?? 'todo'\n const priority = pickedPriority?.meaning ?? 'normal'\n const status = pickedStatus?.meaning ?? 'open'\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n const record: Record<string, unknown> = {\n title,\n // Each meaning with the org's label beside it (AGL-3517).\n kind,\n typeLabel: pickedKind ? pickedKind.label : crmPicklistLabelForNew(picklists.type, kind),\n priority,\n priorityLabel: pickedPriority\n ? pickedPriority.label\n : crmPicklistLabelForNew(picklists.priority, priority),\n status,\n statusLabel: pickedStatus\n ? pickedStatus.label\n : crmPicklistLabelForNew(picklists.status, status),\n // A task created done was completed the instant it was created — the\n // same instant its `createdAt` carries.\n ...(status === 'done' ? { completedAtMs: stamp.createdAt.toMillis() } : {}),\n ...createPayload({\n ...rest,\n // A reminder on a task created done is owed to nobody.\n remindAtMs:\n status === 'done'\n ? null\n : crmTaskReminderAfterEdit({\n dueAtMs: rest.dueAtMs ?? null,\n remindAtMs,\n previous: null,\n }),\n }),\n /*\n * Stored as `null` when the body names none, never left absent: every\n * tasks list orders by `dueAtMs`, and Firestore leaves a document\n * without the ordered field out of the answer (AGL-3321).\n */\n dueAtMs: rest.dueAtMs ?? null,\n ...stamp,\n }\n // What the console's Tasks list searches by (AGL-3321).\n await collection.doc(id).create({ ...record, ...crmTaskListFields(record) })\n await settleNextActivity(ctx, [rest as Record<string, unknown>])\n const view = taskView(await collection.doc(id).get(), picklists)\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `PATCH /v1/tasks/{id}`. */\nasync function updateTask(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const body = await readJsonBody(request)\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n const picklists = await taskPicklistsOf(ctx)\n const parsed = readTaskInput(body, { partial: true }, picklists, snap.data() as Partial<CrmTask>)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'task', parsed.errors)\n const refErrors = await taskRefErrors(ctx, parsed.values)\n if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'task', refErrors)\n\n const { status: pickedStatus, kind: pickedKind, priority: pickedPriority, remindAtMs, ...rest } =\n parsed.values\n const update: Record<string, unknown> = updatePayload(rest)\n // Each meaning with the org's label beside it (AGL-3517).\n if (pickedKind) {\n update.kind = pickedKind.meaning\n update.typeLabel = pickedKind.label\n }\n if (pickedPriority) {\n update.priority = pickedPriority.meaning\n update.priorityLabel = pickedPriority.label\n }\n // A cleared due date is stored `null`, not deleted — see the create.\n if (rest.dueAtMs === null) update.dueAtMs = null\n // One instant for the write: a task completed at T reads updated at T.\n const now = Timestamp.now()\n const status = pickedStatus?.meaning\n const completing = status === 'done' && status !== snap.get('status')\n if (pickedStatus) update.statusLabel = pickedStatus.label\n if (status !== undefined && status !== snap.get('status')) {\n update.status = status\n update.completedAtMs = status === 'done' ? now.toMillis() : null\n }\n /*\n * The reminder the edit leaves: what the body said, else the rule over\n * the stored task and the due date this PATCH moves it to. Completion\n * clears one still owed. A reminder that MOVED — to a time or to none —\n * is one not yet sent, so the runner's mark comes off with it; one that\n * stayed keeps its mark.\n */\n const stored = snap.data() as Partial<CrmTask>\n const previous = {\n dueAtMs: typeof stored.dueAtMs === 'number' ? stored.dueAtMs : null,\n remindAtMs: typeof stored.remindAtMs === 'number' ? stored.remindAtMs : null,\n }\n const nextRemindAtMs =\n completing && crmTaskReminderPending(stored)\n ? null\n : crmTaskReminderAfterEdit({\n dueAtMs: rest.dueAtMs === undefined ? previous.dueAtMs : rest.dueAtMs,\n remindAtMs,\n previous,\n })\n if (nextRemindAtMs !== previous.remindAtMs) {\n update.remindAtMs = nextRemindAtMs === null ? FieldValue.delete() : nextRemindAtMs\n update.reminderSentAtMs = FieldValue.delete()\n }\n if (Object.keys(update).length > 0) {\n await ref.update({ ...update, updatedAt: now })\n await settleNextActivity(ctx, [snap.data(), rest as Record<string, unknown>])\n // What the console's Tasks list searches (AGL-3321).\n if ('title' in update) await restampCrmListFieldsAt(ref, 'crmTasks')\n }\n return apiJson(taskView(await ref.get(), picklists), { headers: ctx.headers })\n}\n\n/** `DELETE /v1/tasks/{id}`. */\nasync function deleteTask(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'task-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n // Read before the delete: the records it named are recomputed without it.\n const named = snap.data()\n await ref.delete()\n await settleNextActivity(ctx, [named])\n const view = { id: ref.id, object: 'task', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/tasks` filters, most selective first. */\nasync function listTasks(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawStatus = url.searchParams.get('status')\n if (rawStatus !== null && rawStatus.trim() !== '' && !(TASK_STATUSES as readonly string[]).includes(rawStatus.trim())) {\n return crmValidationFailed(ctx, 'task filter', {\n status: `Must be one of: ${TASK_STATUSES.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, [\n 'dealId',\n 'contactId',\n 'companyId',\n 'assigneeUid',\n 'status',\n ])\n const picklists = await taskPicklistsOf(ctx)\n return listCrm(ctx, collection, url, filters, (doc) => taskView(doc, picklists))\n}\n\nexport async function handleTasks(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.tasks)\n const [, taskId] = segments\n\n if (!taskId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listTasks(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createTask(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(taskId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such task', headers: ctx.headers })\n }\n return apiJson(taskView(snap, await taskPicklistsOf(ctx)), { headers: ctx.headers })\n }\n if (request.method === 'PATCH') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return updateTask(request, ctx, ref)\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteTask(request, ctx, ref)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, PATCH, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_TASK_PICKLIST_IDS","crmPicklistLabelForNew","crmTaskPicklistLabels","createResourceUid","crmTaskListFields","resolveCrmSemanticPicklistWrite","apiJson","ApiErrors","restampCrmListFieldsAt","FieldValue","Timestamp","claimWrite","readJsonBody","requireScope","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","updatePayload","readCrmTaskPicklists","crmTaskReminderAfterEdit","crmTaskReminderPending","crmNextActivityLinksOf","recomputeCrmNextTaskAt","TASK_STATUSES","taskPicklistsOf","ctx","firestore","orgId","taskView","doc","picklists","data","labels","id","object","title","notes","kind","typeLabel","type","priority","priorityLabel","status","statusLabel","dueAt","dueAtMs","remindAt","remindAtMs","reminderSentAt","reminderSentAtMs","completedAt","completedAtMs","assigneeUid","contactId","companyId","dealId","siteId","hostId","TASK_WRITABLE","Set","readPicklistField","body","key","stored","errors","value","undefined","list","resolved","ok","meaning","label","error","readTaskInput","partial","values","allowed","add","String","trim","slice","ms","field","Object","keys","length","taskRefErrors","assignee","refs","Promise","all","settleNextActivity","links","map","link","console","createTask","request","parsed","site","response","refErrors","collection","tasks","claimed","headers","get","replay","claim","rest","pickedKind","pickedPriority","pickedStatus","stamp","record","createdAt","toMillis","previous","create","view","release","updateTask","ref","snap","exists","notFound","message","update","now","completing","nextRemindAtMs","delete","updatedAt","deleteTask","named","deleted","listTasks","url","rawStatus","searchParams","includes","join","filters","handleTasks","segments","taskId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
@@ -97,7 +97,7 @@ export interface ReassignmentInput extends OwnerAssignmentInput {
97
97
  assign: CrmAssignmentTarget;
98
98
  }
99
99
  /** Why an assignment assigned nobody — the automation reports these verbatim. */
100
- export type OwnerAssignmentRefusal = 'no-org' | 'no-contact' | 'no-rule' | 'empty-pool' | 'not-a-member' | 'failed';
100
+ export type OwnerAssignmentRefusal = 'no-org' | 'no-contact' | 'no-lead' | 'no-rule' | 'empty-pool' | 'not-a-member' | 'failed';
101
101
  /** What decided an assignment: a rule, the site default, the step's member, or the rotation. */
102
102
  export type OwnerAssignedBy = 'rule' | 'default' | 'member' | 'roundRobin';
103
103
  export type OwnerAssignment = {
@@ -159,4 +159,30 @@ export declare function assignOwnerForCapture(input: CaptureAssignmentInput): Pr
159
159
  * owner the record already has changes nothing and tells nobody.
160
160
  */
161
161
  export declare function reassignContactOwner(input: ReassignmentInput): Promise<OwnerAssignment>;
162
+ /** The deliberate assignment of a LEAD the site holds and no contact has absorbed yet. */
163
+ export interface LeadReassignmentInput {
164
+ hostId: string;
165
+ /** `orgs/{orgId}/leads/{leadId}` — the person's key. */
166
+ leadId: string;
167
+ assign: CrmAssignmentTarget;
168
+ /** As {@link OwnerAssignmentInput.actorUid}: never told about their own choice. */
169
+ actorUid?: string | null;
170
+ }
171
+ /**
172
+ * {@link reassignContactOwner} for a person who is still a LEAD (AGL-3458).
173
+ *
174
+ * A lead-routed form files a lead and no contact (AGL-3232), so an
175
+ * automation welcoming that person — "assign an owner, then book them a
176
+ * call" — has only the lead to act on. The owner is the lead's own
177
+ * `ownerUid`, the field the Leads list's Owner column and filter read and a
178
+ * conversion hands to the contact. Same transaction shape as a contact's:
179
+ * the org document, the lead and every roster candidate are read inside it,
180
+ * and the round-robin pointer moves in the commit that writes the owner, so
181
+ * a lead and a contact assigned at the same moment take two different
182
+ * members of the one rotation.
183
+ *
184
+ * Never rejects; a failure is logged and answered as `none`, which the
185
+ * automation records as the step's error.
186
+ */
187
+ export declare function reassignLeadOwner(input: LeadReassignmentInput): Promise<OwnerAssignment>;
162
188
  export default assignOwnerForCapture;
@@ -20,6 +20,7 @@ import { FieldValue } from "firebase-admin/firestore";
20
20
  /** The words the run history and the log give each refusal. */ export const OWNER_ASSIGNMENT_REFUSALS = {
21
21
  'no-org': 'this site has no organization',
22
22
  'no-contact': 'the contact no longer exists',
23
+ 'no-lead': 'the lead no longer exists',
23
24
  'no-rule': 'no assignment rule matched and the site has no default owner',
24
25
  'empty-pool': 'the round-robin pool has nobody on the roster in it',
25
26
  'not-a-member': 'the owner named is not on the team',
@@ -88,6 +89,46 @@ import { FieldValue } from "firebase-admin/firestore";
88
89
  });
89
90
  return true;
90
91
  }
92
+ /**
93
+ * The roster checks one assignment transaction makes: whether a uid is on the
94
+ * team, and who a target names — a member as named, or the next member of
95
+ * the round-robin pool who is still on the roster. One read per candidate
96
+ * per attempt; a retried transaction builds a fresh one and reads afresh, as
97
+ * it must.
98
+ */ function rosterResolver(tx, membersRef, settings) {
99
+ const onRoster = new Map();
100
+ const isMember = (uid)=>{
101
+ let known = onRoster.get(uid);
102
+ if (!known) {
103
+ known = tx.get(membersRef.doc(uid)).then((snapshot)=>snapshot.exists);
104
+ onRoster.set(uid, known);
105
+ }
106
+ return known;
107
+ };
108
+ const fromPool = async ()=>{
109
+ for (const uid of roundRobinOrder(settings.pool.memberUids, settings.pool.lastAssignedUid)){
110
+ if (await isMember(uid)) return uid;
111
+ }
112
+ return null;
113
+ };
114
+ const resolveTarget = async (target)=>{
115
+ if ('roundRobin' in target) {
116
+ const uid = await fromPool();
117
+ return uid ? {
118
+ uid,
119
+ advancePointer: true
120
+ } : null;
121
+ }
122
+ return await isMember(target.memberUid) ? {
123
+ uid: target.memberUid,
124
+ advancePointer: false
125
+ } : null;
126
+ };
127
+ return {
128
+ isMember,
129
+ resolveTarget
130
+ };
131
+ }
91
132
  async function assignOwner(policy) {
92
133
  const { input } = policy;
93
134
  try {
@@ -137,36 +178,7 @@ async function assignOwner(policy) {
137
178
  };
138
179
  }
139
180
  const settings = readCrmAssignmentSettings(org);
140
- // One read per candidate per attempt; a retried transaction reads
141
- // afresh, as it must.
142
- const onRoster = new Map();
143
- const isMember = (uid)=>{
144
- let known = onRoster.get(uid);
145
- if (!known) {
146
- known = tx.get(membersRef.doc(uid)).then((snapshot)=>snapshot.exists);
147
- onRoster.set(uid, known);
148
- }
149
- return known;
150
- };
151
- const fromPool = async ()=>{
152
- for (const uid of roundRobinOrder(settings.pool.memberUids, settings.pool.lastAssignedUid)){
153
- if (await isMember(uid)) return uid;
154
- }
155
- return null;
156
- };
157
- const resolveTarget = async (target)=>{
158
- if ('roundRobin' in target) {
159
- const uid = await fromPool();
160
- return uid ? {
161
- uid,
162
- advancePointer: true
163
- } : null;
164
- }
165
- return await isMember(target.memberUid) ? {
166
- uid: target.memberUid,
167
- advancePointer: false
168
- } : null;
169
- };
181
+ const { isMember, resolveTarget } = rosterResolver(tx, membersRef, settings);
170
182
  let decision = null;
171
183
  let refusal = 'no-rule';
172
184
  if (policy.kind === 'capture') {
@@ -335,6 +347,100 @@ async function assignOwner(policy) {
335
347
  input
336
348
  });
337
349
  }
350
+ /**
351
+ * {@link reassignContactOwner} for a person who is still a LEAD (AGL-3458).
352
+ *
353
+ * A lead-routed form files a lead and no contact (AGL-3232), so an
354
+ * automation welcoming that person — "assign an owner, then book them a
355
+ * call" — has only the lead to act on. The owner is the lead's own
356
+ * `ownerUid`, the field the Leads list's Owner column and filter read and a
357
+ * conversion hands to the contact. Same transaction shape as a contact's:
358
+ * the org document, the lead and every roster candidate are read inside it,
359
+ * and the round-robin pointer moves in the commit that writes the owner, so
360
+ * a lead and a contact assigned at the same moment take two different
361
+ * members of the one rotation.
362
+ *
363
+ * Never rejects; a failure is logged and answered as `none`, which the
364
+ * automation records as the step's error.
365
+ */ export async function reassignLeadOwner(input) {
366
+ try {
367
+ const resolved = await getOrgForHost(input.hostId);
368
+ if (!resolved) return {
369
+ outcome: 'none',
370
+ reason: 'no-org'
371
+ };
372
+ const { orgId } = resolved;
373
+ const firestore = firebaseAdmin.app().firestore();
374
+ const orgRef = firestore.collection('orgs').doc(orgId);
375
+ const leadRef = orgRef.collection('leads').doc(input.leadId);
376
+ const membersRef = orgRef.collection('members');
377
+ let who = '';
378
+ const verdict = await firestore.runTransaction(async (tx)=>{
379
+ var _leadSnapshot_data, _lead_name, _lead_email, _lead_ownerUid, _orgSnapshot_data;
380
+ const [orgSnapshot, leadSnapshot] = await Promise.all([
381
+ tx.get(orgRef),
382
+ tx.get(leadRef)
383
+ ]);
384
+ if (!leadSnapshot.exists) return {
385
+ outcome: 'none',
386
+ reason: 'no-lead'
387
+ };
388
+ const lead = (_leadSnapshot_data = leadSnapshot.data()) != null ? _leadSnapshot_data : {};
389
+ who = String((_lead_name = lead['name']) != null ? _lead_name : '') || String((_lead_email = lead['email']) != null ? _lead_email : '');
390
+ const current = String((_lead_ownerUid = lead['ownerUid']) != null ? _lead_ownerUid : '');
391
+ const settings = readCrmAssignmentSettings((_orgSnapshot_data = orgSnapshot.data()) != null ? _orgSnapshot_data : {});
392
+ const { resolveTarget } = rosterResolver(tx, membersRef, settings);
393
+ const hit = await resolveTarget(input.assign);
394
+ if (!hit) {
395
+ return {
396
+ outcome: 'none',
397
+ reason: 'roundRobin' in input.assign ? 'empty-pool' : 'not-a-member'
398
+ };
399
+ }
400
+ if (hit.uid === current) return {
401
+ outcome: 'unchanged',
402
+ ownerUid: current
403
+ };
404
+ tx.update(leadRef, {
405
+ ownerUid: hit.uid,
406
+ updatedAt: FieldValue.serverTimestamp()
407
+ });
408
+ if (hit.advancePointer) {
409
+ tx.update(orgRef, {
410
+ [CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH]: hit.uid
411
+ });
412
+ }
413
+ return {
414
+ outcome: 'assigned',
415
+ ownerUid: hit.uid,
416
+ by: 'roundRobin' in input.assign ? 'roundRobin' : 'member',
417
+ leadMirrored: false,
418
+ notified: false
419
+ };
420
+ });
421
+ if (verdict.outcome !== 'assigned') return verdict;
422
+ const notified = await notifyRecordAssigned({
423
+ hostId: input.hostId,
424
+ orgId,
425
+ ownerUid: verdict.ownerUid,
426
+ actorUid: input.actorUid,
427
+ record: {
428
+ kind: 'lead',
429
+ id: input.leadId
430
+ },
431
+ who
432
+ });
433
+ return _extends({}, verdict, {
434
+ notified
435
+ });
436
+ } catch (error) {
437
+ console.error('assignLeadOwner failed', input.hostId, input.leadId, error);
438
+ return {
439
+ outcome: 'none',
440
+ reason: 'failed'
441
+ };
442
+ }
443
+ }
338
444
  export default assignOwnerForCapture;
339
445
 
340
446
  //# sourceMappingURL=assign-contact-owner.js.map