@aglyn/plugins-crm 1.0.0-beta.217 → 1.0.0-beta.219
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 +14 -13
- package/src/index.d.ts +1 -0
- package/src/index.js.map +1 -1
- package/src/lib/components/companies-bulk-bar.js +12 -4
- package/src/lib/components/companies-bulk-bar.js.map +1 -1
- package/src/lib/components/company-detail-page.js +11 -0
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +12 -2
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-import-drawer.d.ts +1 -1
- package/src/lib/components/company-picker.js +10 -3
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-detail-page.d.ts +4 -4
- package/src/lib/components/contact-detail-page.js +50 -53
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-list-columns.d.ts +3 -0
- package/src/lib/components/contact-list-columns.js +21 -5
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-merge-dialog.js +2 -1
- package/src/lib/components/contact-merge-dialog.js.map +1 -1
- package/src/lib/components/contact-timeline-card.js +4 -3
- package/src/lib/components/contact-timeline-card.js.map +1 -1
- package/src/lib/components/contacts-bulk-bar.d.ts +3 -3
- package/src/lib/components/contacts-bulk-bar.js +24 -51
- package/src/lib/components/contacts-bulk-bar.js.map +1 -1
- package/src/lib/components/contacts-section.js +9 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-export-all-button.d.ts +1 -1
- package/src/lib/components/crm-export-all-button.js.map +1 -1
- package/src/lib/components/crm-send-email-dialog.js +9 -6
- package/src/lib/components/crm-send-email-dialog.js.map +1 -1
- package/src/lib/components/csv-import-drawer.js +2 -2
- package/src/lib/components/csv-import-drawer.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +18 -2
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +10 -0
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-bulk-bar.js +13 -5
- package/src/lib/components/deals-bulk-bar.js.map +1 -1
- package/src/lib/components/lead-campaigns-card.js +6 -0
- package/src/lib/components/lead-campaigns-card.js.map +1 -1
- package/src/lib/components/lead-detail-page.js +25 -5
- package/src/lib/components/lead-detail-page.js.map +1 -1
- package/src/lib/components/lead-properties-card.js +6 -0
- package/src/lib/components/lead-properties-card.js.map +1 -1
- package/src/lib/components/lead-surfaces-note.js +4 -5
- package/src/lib/components/lead-surfaces-note.js.map +1 -1
- package/src/lib/components/lead-unqualify-dialog.js +6 -0
- package/src/lib/components/lead-unqualify-dialog.js.map +1 -1
- package/src/lib/components/leads-bulk-bar.js +28 -3
- package/src/lib/components/leads-bulk-bar.js.map +1 -1
- package/src/lib/components/leads-section.js +36 -7
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/org-lead-surfaces-note.js +6 -2
- package/src/lib/components/org-lead-surfaces-note.js.map +1 -1
- package/src/lib/components/recipes-card.js +9 -4
- package/src/lib/components/recipes-card.js.map +1 -1
- package/src/lib/components/record-sharing-card.d.ts +57 -0
- package/src/lib/components/record-sharing-card.js +244 -0
- package/src/lib/components/record-sharing-card.js.map +1 -0
- package/src/lib/components/settings-section.js +7 -0
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/components/share-records-dialog.d.ts +28 -0
- package/src/lib/components/share-records-dialog.js +184 -0
- package/src/lib/components/share-records-dialog.js.map +1 -0
- package/src/lib/components/sharing-rule-drawer.d.ts +37 -0
- package/src/lib/components/sharing-rule-drawer.js +374 -0
- package/src/lib/components/sharing-rule-drawer.js.map +1 -0
- package/src/lib/components/sharing-rules-card.d.ts +24 -0
- package/src/lib/components/sharing-rules-card.js +287 -0
- package/src/lib/components/sharing-rules-card.js.map +1 -0
- package/src/lib/components/use-crm-api.d.ts +1 -1
- package/src/lib/components/use-crm-api.js.map +1 -1
- package/src/lib/components/use-site-route-context.d.ts +25 -0
- package/src/lib/components/use-site-route-context.js +58 -0
- package/src/lib/components/use-site-route-context.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +31 -0
- package/src/lib/constants/api-routes.js +27 -1
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +37 -0
- package/src/lib/declarations.console-server.js +103 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/declarations.server.d.ts +6 -0
- package/src/lib/declarations.server.js +33 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-contact-remove.d.ts +35 -0
- package/src/lib/hooks/use-contact-remove.js +77 -0
- package/src/lib/hooks/use-contact-remove.js.map +1 -0
- package/src/lib/hooks/use-crm-sharing.d.ts +30 -0
- package/src/lib/hooks/use-crm-sharing.js +109 -0
- package/src/lib/hooks/use-crm-sharing.js.map +1 -0
- package/src/lib/model/companies-csv.d.ts +1 -1
- package/src/lib/model/companies-csv.js +2 -2
- package/src/lib/model/companies-csv.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +68 -0
- package/src/lib/model/contact-holder.js +129 -0
- package/src/lib/model/contact-holder.js.map +1 -0
- package/src/lib/model/contact-interaction-links.d.ts +46 -0
- package/src/lib/model/contact-interaction-links.js +65 -0
- package/src/lib/model/contact-interaction-links.js.map +1 -0
- package/src/lib/model/contact-merge.d.ts +136 -0
- package/src/lib/model/contact-merge.js +412 -0
- package/src/lib/model/contact-merge.js.map +1 -0
- package/src/lib/model/contact-record.d.ts +7 -1
- package/src/lib/model/contact-record.js +6 -5
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-remove.d.ts +48 -0
- package/src/lib/model/contact-remove.js +22 -0
- package/src/lib/model/contact-remove.js.map +1 -0
- package/src/lib/model/contacts-bulk-writes.d.ts +2 -71
- package/src/lib/model/contacts-bulk-writes.js +2 -82
- package/src/lib/model/contacts-bulk-writes.js.map +1 -1
- package/src/lib/model/contacts-csv.d.ts +2 -2
- package/src/lib/model/contacts-csv.js +2 -2
- package/src/lib/model/contacts-csv.js.map +1 -1
- package/src/lib/model/crm-csv.d.ts +239 -0
- package/src/lib/model/crm-csv.js +467 -0
- package/src/lib/model/crm-csv.js.map +1 -0
- package/src/lib/model/crm-digest.d.ts +185 -0
- package/src/lib/model/crm-digest.js +272 -0
- package/src/lib/model/crm-digest.js.map +1 -0
- package/src/lib/model/crm-import.d.ts +7 -1
- package/src/lib/model/crm-import.js +2 -1
- package/src/lib/model/crm-import.js.map +1 -1
- package/src/lib/model/crm-record-routes.d.ts +5 -0
- package/src/lib/model/crm-record-routes.js +7 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-routes.d.ts +6 -5
- package/src/lib/model/crm-routes.js +7 -7
- package/src/lib/model/crm-routes.js.map +1 -1
- package/src/lib/model/crm-search-sources.d.ts +15 -0
- package/src/lib/model/crm-search-sources.js +162 -0
- package/src/lib/model/crm-search-sources.js.map +1 -0
- package/src/lib/model/crm-sharing-api.d.ts +32 -0
- package/src/lib/model/crm-sharing-api.js +59 -0
- package/src/lib/model/crm-sharing-api.js.map +1 -0
- package/src/lib/model/crm-sharing.d.ts +333 -0
- package/src/lib/model/crm-sharing.js +603 -0
- package/src/lib/model/crm-sharing.js.map +1 -0
- package/src/lib/model/deals-csv.d.ts +1 -1
- package/src/lib/model/deals-csv.js +4 -5
- package/src/lib/model/deals-csv.js.map +1 -1
- package/src/lib/model/lead-filters.d.ts +1 -1
- package/src/lib/model/leads-csv.d.ts +1 -1
- package/src/lib/model/leads-csv.js +1 -1
- package/src/lib/model/leads-csv.js.map +1 -1
- package/src/lib/model/tasks-csv.d.ts +1 -1
- package/src/lib/model/tasks-csv.js +1 -1
- package/src/lib/model/tasks-csv.js.map +1 -1
- package/src/lib/plan-entitlements.d.ts +75 -0
- package/src/lib/plan-entitlements.js +56 -0
- package/src/lib/plan-entitlements.js.map +1 -0
- package/src/lib/plugin.js +3 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/api-v1/contacts-merge.d.ts +20 -0
- package/src/lib/server/api-v1/contacts-merge.js +120 -0
- package/src/lib/server/api-v1/contacts-merge.js.map +1 -0
- package/src/lib/server/api-v1/contacts.d.ts +18 -0
- package/src/lib/server/api-v1/contacts.js +1076 -0
- package/src/lib/server/api-v1/contacts.js.map +1 -0
- package/src/lib/server/api-v1/crm-activities.d.ts +18 -0
- package/src/lib/server/api-v1/crm-activities.js +269 -0
- package/src/lib/server/api-v1/crm-activities.js.map +1 -0
- package/src/lib/server/api-v1/crm-companies.d.ts +18 -0
- package/src/lib/server/api-v1/crm-companies.js +391 -0
- package/src/lib/server/api-v1/crm-companies.js.map +1 -0
- package/src/lib/server/api-v1/crm-deals.d.ts +18 -0
- package/src/lib/server/api-v1/crm-deals.js +631 -0
- package/src/lib/server/api-v1/crm-deals.js.map +1 -0
- package/src/lib/server/api-v1/crm-email-templates.d.ts +18 -0
- package/src/lib/server/api-v1/crm-email-templates.js +336 -0
- package/src/lib/server/api-v1/crm-email-templates.js.map +1 -0
- package/src/lib/server/api-v1/crm-leads.d.ts +18 -0
- package/src/lib/server/api-v1/crm-leads.js +832 -0
- package/src/lib/server/api-v1/crm-leads.js.map +1 -0
- package/src/lib/server/api-v1/crm-pipelines.d.ts +76 -0
- package/src/lib/server/api-v1/crm-pipelines.js +146 -0
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -0
- package/src/lib/server/api-v1/crm-shared.d.ts +271 -0
- package/src/lib/server/api-v1/crm-shared.js +436 -0
- package/src/lib/server/api-v1/crm-shared.js.map +1 -0
- package/src/lib/server/api-v1/crm-tasks.d.ts +18 -0
- package/src/lib/server/api-v1/crm-tasks.js +398 -0
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -0
- package/src/lib/server/api-v1/openapi.d.ts +29 -0
- package/src/lib/server/api-v1/openapi.js +985 -0
- package/src/lib/server/api-v1/openapi.js.map +1 -0
- package/src/lib/server/api-v1/usage.d.ts +26 -0
- package/src/lib/server/api-v1/usage.js +73 -0
- package/src/lib/server/api-v1/usage.js.map +1 -0
- package/src/lib/server/consent-group-participant.js +1 -1
- package/src/lib/server/consent-group-participant.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +89 -0
- package/src/lib/server/contact-lifecycle-floor.js +91 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -0
- package/src/lib/server/contact-remove.d.ts +75 -0
- package/src/lib/server/contact-remove.js +244 -0
- package/src/lib/server/contact-remove.js.map +1 -0
- package/src/lib/server/contact-update.js +2 -1
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/contacts-import.d.ts +47 -11
- package/src/lib/server/contacts-import.js +171 -71
- package/src/lib/server/contacts-import.js.map +1 -1
- package/src/lib/server/contacts-merge.d.ts +1 -1
- package/src/lib/server/contacts-merge.js +3 -2
- package/src/lib/server/contacts-merge.js.map +1 -1
- package/src/lib/server/convert-host-lead.d.ts +189 -0
- package/src/lib/server/convert-host-lead.js +518 -0
- package/src/lib/server/convert-host-lead.js.map +1 -0
- package/src/lib/server/crm-sharing.d.ts +108 -0
- package/src/lib/server/crm-sharing.js +790 -0
- package/src/lib/server/crm-sharing.js.map +1 -0
- package/src/lib/server/daily-digest-route.d.ts +107 -0
- package/src/lib/server/daily-digest-route.js +588 -0
- package/src/lib/server/daily-digest-route.js.map +1 -0
- package/src/lib/server/export-route.d.ts +60 -0
- package/src/lib/server/export-route.js +447 -0
- package/src/lib/server/export-route.js.map +1 -0
- package/src/lib/server/inbound-route.d.ts +74 -0
- package/src/lib/server/inbound-route.js +257 -0
- package/src/lib/server/inbound-route.js.map +1 -0
- package/src/lib/server/lead-convert.d.ts +1 -1
- package/src/lib/server/lead-convert.js +2 -2
- package/src/lib/server/lead-convert.js.map +1 -1
- package/src/lib/server/merge-contacts.d.ts +55 -0
- package/src/lib/server/merge-contacts.js +274 -0
- package/src/lib/server/merge-contacts.js.map +1 -0
- package/src/lib/server/pipeline-record-index.d.ts +36 -0
- package/src/lib/server/pipeline-record-index.js +87 -0
- package/src/lib/server/pipeline-record-index.js.map +1 -0
- package/src/lib/server/record-facts.js +2 -1
- package/src/lib/server/record-facts.js.map +1 -1
- package/src/lib/server/task-reminders-route.d.ts +82 -0
- package/src/lib/server/task-reminders-route.js +497 -0
- package/src/lib/server/task-reminders-route.js.map +1 -0
- package/src/lib/server-deal-stage.js +2 -1
- package/src/lib/server-deal-stage.js.map +1 -1
- package/src/lib/server.d.ts +5 -2
- package/src/lib/server.js +47 -2
- package/src/lib/server.js.map +1 -1
- package/src/lib/testing/stand-in-site-record-routes.d.ts +26 -0
- package/src/lib/testing/stand-in-site-record-routes.js +55 -0
- package/src/lib/testing/stand-in-site-record-routes.js.map +1 -0
- package/src/lib/usage-axes.d.ts +35 -0
- package/src/lib/usage-axes.js +68 -0
- package/src/lib/usage-axes.js.map +1 -0
- package/src/lib/components/use-emails-hub-path.d.ts +0 -24
- package/src/lib/components/use-emails-hub-path.js +0 -59
- package/src/lib/components/use-emails-hub-path.js.map +0 -1
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
3
|
+
/**
|
|
4
|
+
* @license
|
|
5
|
+
* Copyright 2026 Aglyn LLC
|
|
6
|
+
*
|
|
7
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
* you may not use this file except in compliance with the License.
|
|
9
|
+
* You may obtain a copy of the License at
|
|
10
|
+
*
|
|
11
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
*
|
|
13
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
* See the License for the specific language governing permissions and
|
|
17
|
+
* limitations under the License.
|
|
18
|
+
*/ /**
|
|
19
|
+
* `/v1/leads` (AGL-2627) — the work queue: every person a site captured,
|
|
20
|
+
* with the working state a sales team keeps on them and the door that turns
|
|
21
|
+
* one into a contact.
|
|
22
|
+
*
|
|
23
|
+
* ## A lead belongs to a site, so every request names one
|
|
24
|
+
*
|
|
25
|
+
* The five CRM collections sit under the org and a key reads them whole
|
|
26
|
+
* (`crm-shared.ts`). A lead does not: it lives at
|
|
27
|
+
* `hosts/{siteId}/leads/{id}`, private to the site that captured it, with no
|
|
28
|
+
* `visibleTo` to filter on and an id that is only unique within its site —
|
|
29
|
+
* the person key, so one address is one row per site. There is no org-wide
|
|
30
|
+
* list to read, and `/v1/leads/{id}` alone names a row in every site the org
|
|
31
|
+
* has. So `siteId` is required on every endpoint here, as a query parameter
|
|
32
|
+
* (a `PATCH` or `POST` may carry it in the body instead), and a site the
|
|
33
|
+
* organization does not own is refused the way `consentSiteId` is on a
|
|
34
|
+
* create. That is also what keeps this resource mountable at the org level:
|
|
35
|
+
* the site is a parameter, never an assumption.
|
|
36
|
+
*
|
|
37
|
+
* ## Newest first, and the status filter runs on the page
|
|
38
|
+
*
|
|
39
|
+
* A polling integration asks for what arrived since it last looked, so the
|
|
40
|
+
* list is ordered by `lastSeen` descending — the one field the capture door
|
|
41
|
+
* stamps on every lead, at every capture — with the id as tiebreak, and the
|
|
42
|
+
* cursor carries both. `?status=` is applied to the page rather than sent to
|
|
43
|
+
* Firestore, for a reason a `where` cannot answer: a lead nobody has touched
|
|
44
|
+
* carries no `status` field at all and reads as `new` (`crmLeadStatus`), so
|
|
45
|
+
* `where('status', '==', 'new')` would miss exactly the leads a work queue
|
|
46
|
+
* exists to show. The page can come back short, which the conventions page
|
|
47
|
+
* already documents; no composite index is needed and none is shipped.
|
|
48
|
+
*
|
|
49
|
+
* ## A lead is a record of its own (AGL-3231)
|
|
50
|
+
*
|
|
51
|
+
* `POST /v1/leads` creates one — Salesforce's lead, sourced outside the
|
|
52
|
+
* site and entered before anyone has qualified it: the address, a name,
|
|
53
|
+
* the company as text, a title, a phone, a website, an address, tags and a
|
|
54
|
+
* lead source, with the working state beside them. It goes through the one
|
|
55
|
+
* lead door (`addHostLead`), so a second create for one address updates the
|
|
56
|
+
* lead the site holds and answers `200` rather than minting a second row;
|
|
57
|
+
* it writes no contact and no company, which are the conversion's to make;
|
|
58
|
+
* and it records no marketing basis, because an integration is not a box
|
|
59
|
+
* the person ticked. The same profile fields are writable on a `PATCH`,
|
|
60
|
+
* `null` clearing one, and every one of them is published on the lead.
|
|
61
|
+
*
|
|
62
|
+
* ## What a PATCH may say about a status
|
|
63
|
+
*
|
|
64
|
+
* The same three moves the console offers: `new`, `working`, and
|
|
65
|
+
* `unqualified` with a reason. `qualified` is the converted state — a lead
|
|
66
|
+
* reaches it through `POST /v1/leads/{id}/convert` and nowhere else, because
|
|
67
|
+
* the status is a claim that a contact exists and the conversion is what
|
|
68
|
+
* makes it true. A converted lead's status is fixed for the same reason.
|
|
69
|
+
* Reopening an unqualified lead drops its reason, as the console does: a
|
|
70
|
+
* lead being worked again is not "unqualified because …".
|
|
71
|
+
*/ import { checkVisitorRecordCeiling, CRM_LEAD_STATUSES, crmLeadStatus, crmMemberOption, findOrgMember, LEADS_MAX_PER_HOST, normalizeCompanyDomain, normalizeContactEmail, crmPicklistDefaultLabel, judgeCrmLeadSource, normalizeCrmLeadProfile, personKey, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
|
|
72
|
+
import { scopeTokensForHost } from "@aglyn/aglyn/app-utils/scope-tokens";
|
|
73
|
+
import { addHostLead, apiJson, ApiErrors, decodeCursor, encodeCursor, listOrgMembers, listResponse, parseLimit, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
|
|
74
|
+
import { convertHostLead } from "../convert-host-lead.js";
|
|
75
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
76
|
+
import { claimWrite, orgOwnsHost, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
77
|
+
import { CRM_ID_MAX, CRM_LABEL_MAX, CRM_TEXT_MAX, CRM_TITLE_MAX, crmCustomUpdate, crmCustomView, crmTimes, crmValidationFailed, isoFromMs, memberError, readCrmCustomBody, readOptionalText, readOrgLeadSourcePicklist, refuseUnknownKeys, updatePayload } from "./crm-shared.js";
|
|
78
|
+
/**
|
|
79
|
+
* The statuses a PATCH may set — every status but the converted one.
|
|
80
|
+
*
|
|
81
|
+
* A literal, checked against the model's union, rather than a filter over
|
|
82
|
+
* `CRM_LEAD_STATUSES`: nothing in this module may dereference the model at
|
|
83
|
+
* load time, for the reason `crm-shared.ts` gives — the `/v1` suites mock
|
|
84
|
+
* `@aglyn/aglyn/server` as a closed world, and a module-level read of a
|
|
85
|
+
* constant they never asked for throws at import in every one of them.
|
|
86
|
+
*/ const LEAD_PATCH_STATUSES = [
|
|
87
|
+
'new',
|
|
88
|
+
'working',
|
|
89
|
+
'unqualified'
|
|
90
|
+
];
|
|
91
|
+
// ── The view ────────────────────────────────────────────────────────────────
|
|
92
|
+
/** The lead object as published. Every writable field appears here. */ function leadView(siteId, doc) {
|
|
93
|
+
var _doc_data, _data_ownerUid, _data_notes, _data_unqualifiedReason, _data_company, _data_jobTitle, _data_phone, _data_website, _data_address, _data_leadSource, _data_convertedContactId, _data_companyId, _data_dealId;
|
|
94
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
95
|
+
// The lead's own silo — a group of one, however the org pools its
|
|
96
|
+
// contacts — which is where `addHostLead` recorded the basis.
|
|
97
|
+
const consent = readMarketingBasis(data, soloConsentGroup(siteId));
|
|
98
|
+
return _extends({
|
|
99
|
+
id: doc.id,
|
|
100
|
+
object: 'lead',
|
|
101
|
+
siteId,
|
|
102
|
+
email: typeof data['email'] === 'string' ? data['email'] : null,
|
|
103
|
+
name: typeof data['name'] === 'string' ? data['name'] : null,
|
|
104
|
+
status: crmLeadStatus(data),
|
|
105
|
+
ownerUid: (_data_ownerUid = data.ownerUid) != null ? _data_ownerUid : null,
|
|
106
|
+
notes: (_data_notes = data.notes) != null ? _data_notes : null,
|
|
107
|
+
unqualifiedReason: (_data_unqualifiedReason = data.unqualifiedReason) != null ? _data_unqualifiedReason : null,
|
|
108
|
+
// The lead's own profile (AGL-3231).
|
|
109
|
+
company: (_data_company = data.company) != null ? _data_company : null,
|
|
110
|
+
jobTitle: (_data_jobTitle = data.jobTitle) != null ? _data_jobTitle : null,
|
|
111
|
+
phone: (_data_phone = data.phone) != null ? _data_phone : null,
|
|
112
|
+
website: (_data_website = data.website) != null ? _data_website : null,
|
|
113
|
+
address: (_data_address = data.address) != null ? _data_address : null,
|
|
114
|
+
tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
|
|
115
|
+
leadSource: (_data_leadSource = data.leadSource) != null ? _data_leadSource : null,
|
|
116
|
+
sources: Array.isArray(data['sources']) ? data['sources'].map(String) : [],
|
|
117
|
+
submissionCount: typeof data['submissionCount'] === 'number' ? data['submissionCount'] : 0,
|
|
118
|
+
firstSeen: isoFromMs(data['firstSeenAtMs']),
|
|
119
|
+
lastSeen: isoFromMs(data['lastSeenAtMs']),
|
|
120
|
+
marketingConsent: consent.basis === 'granted',
|
|
121
|
+
marketingConsentAt: consent.basis === 'granted' ? isoFromMs(consent.basisAtMs) : null,
|
|
122
|
+
convertedContactId: (_data_convertedContactId = data.convertedContactId) != null ? _data_convertedContactId : null,
|
|
123
|
+
convertedAt: isoFromMs(data.convertedAtMs),
|
|
124
|
+
companyId: (_data_companyId = data.companyId) != null ? _data_companyId : null,
|
|
125
|
+
dealId: (_data_dealId = data.dealId) != null ? _data_dealId : null,
|
|
126
|
+
// The org's own lead fields (AGL-3272) — `{}` on a lead that carries
|
|
127
|
+
// none, so a client can index it without a guard.
|
|
128
|
+
custom: crmCustomView(data)
|
|
129
|
+
}, crmTimes(data));
|
|
130
|
+
}
|
|
131
|
+
// ── The site ────────────────────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* The `siteId` a request names, validated — the query parameter first, the
|
|
134
|
+
* body as the fallback a `POST`/`PATCH` may use. Required, and required to
|
|
135
|
+
* be one of the organization's own sites, for the reason the module header
|
|
136
|
+
* gives.
|
|
137
|
+
*/ function readLeadSite(ctx, url, body = {}) {
|
|
138
|
+
var _url_searchParams_get;
|
|
139
|
+
const refuse = (error)=>({
|
|
140
|
+
response: crmValidationFailed(ctx, 'lead', {
|
|
141
|
+
siteId: error
|
|
142
|
+
})
|
|
143
|
+
});
|
|
144
|
+
const raw = (_url_searchParams_get = url.searchParams.get('siteId')) != null ? _url_searchParams_get : body.siteId;
|
|
145
|
+
if (raw === undefined || raw === null) {
|
|
146
|
+
return refuse('Required — name the site the lead belongs to');
|
|
147
|
+
}
|
|
148
|
+
const siteId = String(raw).trim();
|
|
149
|
+
if (!siteId) return refuse('Must name a site');
|
|
150
|
+
if (!orgOwnsHost(ctx, siteId)) return refuse('No such site in this organization');
|
|
151
|
+
return {
|
|
152
|
+
siteId
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The org's leads (AGL-3275). The site is still named by every caller — a
|
|
157
|
+
* lead is created and listed in a site's context — but it selects by
|
|
158
|
+
* `visibleTo` now rather than by the collection it lives in, so
|
|
159
|
+
* {@link leadsVisibleTo} is what a LIST must use. This bare ref is for
|
|
160
|
+
* `doc(id)` reads and writes, which the route authorizes for itself.
|
|
161
|
+
*/ function leadsCollection(ctx, _siteId) {
|
|
162
|
+
return ctx.firestore.collection('orgs').doc(ctx.orgId).collection('leads');
|
|
163
|
+
}
|
|
164
|
+
/** The same collection, narrowed to the leads one site may see. */ function leadsVisibleTo(ctx, siteId) {
|
|
165
|
+
return leadsCollection(ctx, siteId).where('visibleTo', 'array-contains-any', scopeTokensForHost(siteId));
|
|
166
|
+
}
|
|
167
|
+
// ── The owner ───────────────────────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* The owner a body names, by uid or by address, resolved to a uid on the
|
|
170
|
+
* roster — or `null` to clear, or `undefined` when the body named nobody.
|
|
171
|
+
*
|
|
172
|
+
* `ownerEmail` is what a spreadsheet or a zap has; a uid is what the record
|
|
173
|
+
* stores. The address resolves the way the CRM's own import and automation
|
|
174
|
+
* steps resolve one (AGL-2614): against the roster alone, so an address the
|
|
175
|
+
* project's auth knows but the organization does not names nobody. One
|
|
176
|
+
* roster read, paid only when an address was given.
|
|
177
|
+
*/ async function readOwner(ctx, body, errors) {
|
|
178
|
+
const ownerUid = readOptionalText(body, 'ownerUid', CRM_TITLE_MAX, errors);
|
|
179
|
+
const ownerEmail = readOptionalText(body, 'ownerEmail', CRM_TITLE_MAX, errors);
|
|
180
|
+
if (ownerUid !== undefined && ownerEmail !== undefined) {
|
|
181
|
+
errors.ownerEmail = 'Name the owner once — by ownerUid or by ownerEmail, not both';
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
if (ownerEmail === undefined) return ownerUid;
|
|
185
|
+
if (ownerEmail === null) return null;
|
|
186
|
+
const email = normalizeContactEmail(ownerEmail);
|
|
187
|
+
if (!email) {
|
|
188
|
+
errors.ownerEmail = 'Must be an email address';
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
const members = await listOrgMembers(ctx.orgId);
|
|
192
|
+
const roster = members.map((member)=>crmMemberOption(member)).filter((option)=>option !== null);
|
|
193
|
+
const found = findOrgMember(roster, email);
|
|
194
|
+
if (!found) {
|
|
195
|
+
errors.ownerEmail = 'No member of this organization has this address';
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
return found.uid;
|
|
199
|
+
}
|
|
200
|
+
// ── PATCH ───────────────────────────────────────────────────────────────────
|
|
201
|
+
/** The lead's own fields, writable on a create and a PATCH alike (AGL-3231). */ const LEAD_PROFILE_KEYS = [
|
|
202
|
+
'company',
|
|
203
|
+
'jobTitle',
|
|
204
|
+
'phone',
|
|
205
|
+
'website',
|
|
206
|
+
'address',
|
|
207
|
+
'tags',
|
|
208
|
+
'leadSource'
|
|
209
|
+
];
|
|
210
|
+
const LEAD_WRITABLE = new Set([
|
|
211
|
+
'siteId',
|
|
212
|
+
'status',
|
|
213
|
+
'ownerUid',
|
|
214
|
+
'ownerEmail',
|
|
215
|
+
'notes',
|
|
216
|
+
'unqualifiedReason',
|
|
217
|
+
// The org's own lead fields (AGL-3272), judged against the definitions
|
|
218
|
+
// whose object is `lead` — never read by `readLeadInput`, which knows
|
|
219
|
+
// nothing about the org's definitions, but named here so a body that
|
|
220
|
+
// carries one is not refused as an unknown key.
|
|
221
|
+
'custom',
|
|
222
|
+
...LEAD_PROFILE_KEYS
|
|
223
|
+
]);
|
|
224
|
+
/** The profile as an update: a cleared field is deleted, not blanked. */ function profileUpdate(patch) {
|
|
225
|
+
const update = {};
|
|
226
|
+
for (const [key, value] of Object.entries(patch)){
|
|
227
|
+
if (value === undefined) continue;
|
|
228
|
+
update[key] = value === null ? FieldValue.delete() : value;
|
|
229
|
+
}
|
|
230
|
+
return update;
|
|
231
|
+
}
|
|
232
|
+
function readLeadInput(body) {
|
|
233
|
+
const errors = {};
|
|
234
|
+
const values = {
|
|
235
|
+
profile: {}
|
|
236
|
+
};
|
|
237
|
+
refuseUnknownKeys(body, LEAD_WRITABLE, 'lead', errors);
|
|
238
|
+
const profileBody = {};
|
|
239
|
+
for (const key of LEAD_PROFILE_KEYS){
|
|
240
|
+
if (key in body) profileBody[key] = body[key];
|
|
241
|
+
}
|
|
242
|
+
const profile = normalizeCrmLeadProfile(profileBody);
|
|
243
|
+
Object.assign(errors, profile.errors);
|
|
244
|
+
values.profile = profile.patch;
|
|
245
|
+
if (body.status !== undefined) {
|
|
246
|
+
if (body.status === 'qualified') {
|
|
247
|
+
errors.status = 'Not settable — a lead is qualified by converting it: POST /v1/leads/{id}/convert';
|
|
248
|
+
} else if (typeof body.status === 'string' && LEAD_PATCH_STATUSES.includes(body.status)) {
|
|
249
|
+
values.status = body.status;
|
|
250
|
+
} else {
|
|
251
|
+
errors.status = `Must be one of: ${LEAD_PATCH_STATUSES.join(', ')}`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors);
|
|
255
|
+
if (notes !== undefined) values.notes = notes;
|
|
256
|
+
const reason = readOptionalText(body, 'unqualifiedReason', CRM_TEXT_MAX, errors);
|
|
257
|
+
if (reason !== undefined) values.unqualifiedReason = reason;
|
|
258
|
+
return Object.keys(errors).length ? {
|
|
259
|
+
errors
|
|
260
|
+
} : {
|
|
261
|
+
values
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** `PATCH /v1/leads/{id}`. */ async function updateLead(request, ctx, url, leadId) {
|
|
265
|
+
var _snap_data;
|
|
266
|
+
const body = await readJsonBody(request);
|
|
267
|
+
const parsed = readLeadInput(body);
|
|
268
|
+
if ('errors' in parsed) return crmValidationFailed(ctx, 'lead', parsed.errors);
|
|
269
|
+
const site = readLeadSite(ctx, url, body);
|
|
270
|
+
if ('response' in site) return site.response;
|
|
271
|
+
const ref = leadsCollection(ctx, site.siteId).doc(leadId);
|
|
272
|
+
const snap = await ref.get();
|
|
273
|
+
if (!snap.exists) {
|
|
274
|
+
return ApiErrors.notFound({
|
|
275
|
+
message: 'No such lead',
|
|
276
|
+
headers: ctx.headers
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const stored = (_snap_data = snap.data()) != null ? _snap_data : {};
|
|
280
|
+
const _parsed_values = parsed.values, { status, unqualifiedReason, profile } = _parsed_values, rest = _object_without_properties_loose(_parsed_values, [
|
|
281
|
+
"status",
|
|
282
|
+
"unqualifiedReason",
|
|
283
|
+
"profile"
|
|
284
|
+
]);
|
|
285
|
+
if (status !== undefined && stored.convertedContactId) {
|
|
286
|
+
return ApiErrors.conflict({
|
|
287
|
+
message: 'This lead was converted; its status is fixed',
|
|
288
|
+
code: 'lead_converted',
|
|
289
|
+
headers: ctx.headers
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const errors = {};
|
|
293
|
+
const ownerUid = await readOwner(ctx, body, errors);
|
|
294
|
+
if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
|
|
295
|
+
const current = crmLeadStatus(stored);
|
|
296
|
+
const next = status != null ? status : current;
|
|
297
|
+
/*
|
|
298
|
+
* A reason travels with the closed state and only with it. Sent beside
|
|
299
|
+
* `status: "unqualified"` it is required; sent on its own it changes the
|
|
300
|
+
* reason of a lead that is already unqualified; sent with any other
|
|
301
|
+
* status it describes a state the lead is not in.
|
|
302
|
+
*/ if (next === 'unqualified') {
|
|
303
|
+
const reason = unqualifiedReason !== undefined ? unqualifiedReason : stored.unqualifiedReason;
|
|
304
|
+
if (!reason) {
|
|
305
|
+
errors.unqualifiedReason = 'A reason is required to mark a lead unqualified';
|
|
306
|
+
}
|
|
307
|
+
} else if (unqualifiedReason) {
|
|
308
|
+
errors.unqualifiedReason = 'Only an unqualified lead carries a reason';
|
|
309
|
+
}
|
|
310
|
+
/*
|
|
311
|
+
* THE ORG'S LEAD SOURCES (AGL-3298): a restricted picklist. An active
|
|
312
|
+
* value is stored as the list spells it; the value the lead already
|
|
313
|
+
* holds is kept even when it has since been deactivated; anything else
|
|
314
|
+
* is a 400 naming the values the list allows.
|
|
315
|
+
*/ if (typeof profile.leadSource === 'string') {
|
|
316
|
+
const judged = judgeCrmLeadSource(await readOrgLeadSourcePicklist(ctx), profile.leadSource, stored.leadSource);
|
|
317
|
+
if (judged.ok === false) errors.leadSource = judged.error;
|
|
318
|
+
else profile.leadSource = judged.value;
|
|
319
|
+
}
|
|
320
|
+
if (Object.keys(errors).length) return crmValidationFailed(ctx, 'lead', errors);
|
|
321
|
+
// The org's own lead fields (AGL-3272), judged against the definitions
|
|
322
|
+
// whose object is `lead` — a contact field of the same key is a
|
|
323
|
+
// different field with a possibly different type.
|
|
324
|
+
const custom = await readCrmCustomBody(ctx, body, 'lead');
|
|
325
|
+
if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'lead', custom.errors);
|
|
326
|
+
const update = _extends({}, updatePayload(_extends({}, rest, {
|
|
327
|
+
ownerUid
|
|
328
|
+
})), profileUpdate(profile), custom && 'values' in custom ? crmCustomUpdate(custom.values) : {});
|
|
329
|
+
if (status !== undefined && status !== current) update.status = status;
|
|
330
|
+
if (next === 'unqualified') {
|
|
331
|
+
if (unqualifiedReason !== undefined) update.unqualifiedReason = unqualifiedReason;
|
|
332
|
+
} else if (stored.unqualifiedReason !== undefined) {
|
|
333
|
+
// Reopening drops the reason with the closed state.
|
|
334
|
+
update.unqualifiedReason = FieldValue.delete();
|
|
335
|
+
}
|
|
336
|
+
if (Object.keys(update).length > 0) {
|
|
337
|
+
await ref.update(_extends({}, update, {
|
|
338
|
+
updatedAt: Timestamp.now()
|
|
339
|
+
}));
|
|
340
|
+
// What the console's Leads list searches and filters by (AGL-3321).
|
|
341
|
+
await restampCrmListFieldsAt(ref, 'leads');
|
|
342
|
+
}
|
|
343
|
+
return apiJson(leadView(site.siteId, await ref.get()), {
|
|
344
|
+
headers: ctx.headers
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
// ── Create ──────────────────────────────────────────────────────────────────
|
|
348
|
+
const LEAD_CREATE_STATUSES = [
|
|
349
|
+
'new',
|
|
350
|
+
'working'
|
|
351
|
+
];
|
|
352
|
+
const LEAD_NAME_MAX = 120;
|
|
353
|
+
/** The surface a lead created over the API names, beside `signup`, `booking`, `form:{id}`, `import` and `manual`. */ const LEAD_API_SOURCE = 'api';
|
|
354
|
+
/**
|
|
355
|
+
* `POST /v1/leads` (AGL-3231). Accepts an `Idempotency-Key`, scoped to the
|
|
356
|
+
* site. Through the one lead door, so one address is one lead per site: a
|
|
357
|
+
* second create updates and answers `200`.
|
|
358
|
+
*/ async function createLead(request, ctx, url) {
|
|
359
|
+
const body = await readJsonBody(request);
|
|
360
|
+
const errors = {};
|
|
361
|
+
refuseUnknownKeys(body, new Set([
|
|
362
|
+
...LEAD_WRITABLE,
|
|
363
|
+
'email',
|
|
364
|
+
'name'
|
|
365
|
+
]), 'lead', errors);
|
|
366
|
+
const email = normalizeContactEmail(body.email);
|
|
367
|
+
if (!email) errors.email = 'Required — an email address';
|
|
368
|
+
const name = readOptionalText(body, 'name', LEAD_NAME_MAX, errors);
|
|
369
|
+
const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors);
|
|
370
|
+
let status;
|
|
371
|
+
if (body.status !== undefined && body.status !== null) {
|
|
372
|
+
if (typeof body.status === 'string' && LEAD_CREATE_STATUSES.includes(body.status)) {
|
|
373
|
+
status = body.status;
|
|
374
|
+
} else {
|
|
375
|
+
errors.status = `Must be one of: ${LEAD_CREATE_STATUSES.join(', ')}`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (body.unqualifiedReason !== undefined) {
|
|
379
|
+
errors.unqualifiedReason = 'Not settable on a create — unqualify with a PATCH';
|
|
380
|
+
}
|
|
381
|
+
const profileBody = {};
|
|
382
|
+
for (const key of LEAD_PROFILE_KEYS){
|
|
383
|
+
if (key in body) profileBody[key] = body[key];
|
|
384
|
+
}
|
|
385
|
+
const profile = normalizeCrmLeadProfile(profileBody);
|
|
386
|
+
Object.assign(errors, profile.errors);
|
|
387
|
+
const site = readLeadSite(ctx, url, body);
|
|
388
|
+
if ('response' in site) {
|
|
389
|
+
if (Object.keys(errors).length) {
|
|
390
|
+
return crmValidationFailed(ctx, 'lead', _extends({}, errors, {
|
|
391
|
+
siteId: 'Required — name the site the lead belongs to'
|
|
392
|
+
}));
|
|
393
|
+
}
|
|
394
|
+
return site.response;
|
|
395
|
+
}
|
|
396
|
+
const ownerUid = await readOwner(ctx, body, errors);
|
|
397
|
+
if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
|
|
398
|
+
if (Object.keys(errors).length) return crmValidationFailed(ctx, 'lead', errors);
|
|
399
|
+
// Judged above the claim (AGL-3272), so a refused field costs no
|
|
400
|
+
// idempotency key — the shape the companies resource writes.
|
|
401
|
+
const custom = await readCrmCustomBody(ctx, body, 'lead');
|
|
402
|
+
if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'lead', custom.errors);
|
|
403
|
+
const customValues = custom && 'values' in custom ? custom.values : {};
|
|
404
|
+
const claimed = await claimWrite(ctx, site.siteId, request.headers.get('Idempotency-Key'), 'lead-creates');
|
|
405
|
+
if ('replay' in claimed) return claimed.replay;
|
|
406
|
+
const { claim } = claimed;
|
|
407
|
+
try {
|
|
408
|
+
const hostRef = ctx.firestore.collection('hosts').doc(site.siteId);
|
|
409
|
+
const leadsRef = leadsCollection(ctx, site.siteId);
|
|
410
|
+
// Non-null by construction: the normalizer refused every address this
|
|
411
|
+
// derivation could not key.
|
|
412
|
+
const leadId = personKey(email);
|
|
413
|
+
const existing = await leadsRef.doc(leadId).get();
|
|
414
|
+
const created = !existing.exists;
|
|
415
|
+
/*
|
|
416
|
+
* THE ORG'S LEAD SOURCES (AGL-3298), judged once the lead's own value
|
|
417
|
+
* is known: a value outside the list is a 400 naming what it allows, a
|
|
418
|
+
* lead the site already held keeps its current value, and a new lead
|
|
419
|
+
* that named none starts from the list's default.
|
|
420
|
+
*/ const leadSources = await readOrgLeadSourcePicklist(ctx);
|
|
421
|
+
if (typeof profile.patch.leadSource === 'string') {
|
|
422
|
+
const judged = judgeCrmLeadSource(leadSources, profile.patch.leadSource, existing.get('leadSource'));
|
|
423
|
+
if (judged.ok === false) {
|
|
424
|
+
await claim.release();
|
|
425
|
+
return crmValidationFailed(ctx, 'lead', {
|
|
426
|
+
leadSource: judged.error
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
profile.patch.leadSource = judged.value;
|
|
430
|
+
} else if (profile.patch.leadSource === undefined && created) {
|
|
431
|
+
const fallback = crmPicklistDefaultLabel(leadSources);
|
|
432
|
+
if (fallback) profile.patch.leadSource = fallback;
|
|
433
|
+
}
|
|
434
|
+
if (created) {
|
|
435
|
+
// What the SITE may see (AGL-3275), matching `addHostLead`'s own count:
|
|
436
|
+
// an unscoped count would charge one site for a sibling's leads.
|
|
437
|
+
const used = (await leadsVisibleTo(ctx, site.siteId).count().get()).data().count;
|
|
438
|
+
if (checkVisitorRecordCeiling(used, LEADS_MAX_PER_HOST).exceeded) {
|
|
439
|
+
await claim.release();
|
|
440
|
+
return ApiErrors.conflict({
|
|
441
|
+
message: 'This site is at the platform lead limit, so the lead was not ' + 'created. Remove some leads, or contact support if this is real traffic.',
|
|
442
|
+
code: 'lead_ceiling',
|
|
443
|
+
headers: ctx.headers
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const stored = await addHostLead({
|
|
448
|
+
hostRef,
|
|
449
|
+
hostId: site.siteId,
|
|
450
|
+
lead: _extends({
|
|
451
|
+
email: email
|
|
452
|
+
}, name ? {
|
|
453
|
+
name
|
|
454
|
+
} : {}, {
|
|
455
|
+
source: LEAD_API_SOURCE
|
|
456
|
+
})
|
|
457
|
+
});
|
|
458
|
+
if (!stored) {
|
|
459
|
+
await claim.release();
|
|
460
|
+
return ApiErrors.conflict({
|
|
461
|
+
message: 'The lead could not be stored',
|
|
462
|
+
code: 'lead_not_stored',
|
|
463
|
+
headers: ctx.headers
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const working = _extends({}, profileUpdate(profile.patch), status ? {
|
|
467
|
+
status
|
|
468
|
+
} : {}, ownerUid ? {
|
|
469
|
+
ownerUid
|
|
470
|
+
} : {}, notes ? {
|
|
471
|
+
notes
|
|
472
|
+
} : {}, Object.keys(customValues).length ? {
|
|
473
|
+
custom: customValues
|
|
474
|
+
} : {});
|
|
475
|
+
const ref = leadsRef.doc(leadId);
|
|
476
|
+
if (Object.keys(working).length) {
|
|
477
|
+
await ref.set(_extends({}, working, {
|
|
478
|
+
updatedAt: Timestamp.now()
|
|
479
|
+
}), {
|
|
480
|
+
merge: true
|
|
481
|
+
});
|
|
482
|
+
// What the console's Leads list searches and filters by (AGL-3321).
|
|
483
|
+
await restampCrmListFieldsAt(ref, 'leads');
|
|
484
|
+
}
|
|
485
|
+
const view = leadView(site.siteId, await ref.get());
|
|
486
|
+
await claim.record(created ? 201 : 200, view);
|
|
487
|
+
return apiJson(view, {
|
|
488
|
+
status: created ? 201 : 200,
|
|
489
|
+
headers: ctx.headers
|
|
490
|
+
});
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await claim.release();
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ── Convert ─────────────────────────────────────────────────────────────────
|
|
497
|
+
const CONVERT_KEYS = new Set([
|
|
498
|
+
'siteId',
|
|
499
|
+
'company',
|
|
500
|
+
'deal',
|
|
501
|
+
'ownerUid',
|
|
502
|
+
'ownerEmail'
|
|
503
|
+
]);
|
|
504
|
+
const CURRENCY_PATTERN = /^[a-z]{3}$/;
|
|
505
|
+
const isPlainObject = (value)=>Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
506
|
+
/**
|
|
507
|
+
* The convert body: `company` is `{ link }`, `{ create }` or `null`; `deal`
|
|
508
|
+
* is what to open, or `null`. Each key is named in full — `company.link`,
|
|
509
|
+
* `deal.amountCents` — so a refused nested field is the one the integrator
|
|
510
|
+
* sent.
|
|
511
|
+
*/ function readConvertInput(body) {
|
|
512
|
+
const errors = {};
|
|
513
|
+
const plan = {};
|
|
514
|
+
refuseUnknownKeys(body, CONVERT_KEYS, 'conversion', errors);
|
|
515
|
+
if (body.company !== undefined && body.company !== null) {
|
|
516
|
+
if (!isPlainObject(body.company)) {
|
|
517
|
+
errors.company = 'Must be { "link": id }, { "create": { … } } or null';
|
|
518
|
+
} else {
|
|
519
|
+
const company = body.company;
|
|
520
|
+
const keys = Object.keys(company);
|
|
521
|
+
const shape = keys.length === 1 ? keys[0] : null;
|
|
522
|
+
if (shape === 'link') {
|
|
523
|
+
const link = company.link;
|
|
524
|
+
if (typeof link !== 'string' || !link.trim() || link.trim().length > CRM_ID_MAX) {
|
|
525
|
+
errors['company.link'] = 'Must be a company id';
|
|
526
|
+
} else {
|
|
527
|
+
plan.companyId = link.trim();
|
|
528
|
+
}
|
|
529
|
+
} else if (shape === 'create' && isPlainObject(company.create)) {
|
|
530
|
+
var _create_name;
|
|
531
|
+
const create = company.create;
|
|
532
|
+
for (const key of Object.keys(create)){
|
|
533
|
+
if (key !== 'name' && key !== 'domain') {
|
|
534
|
+
errors[`company.create.${key}`] = 'Not writable on a company created here';
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const name = String((_create_name = create.name) != null ? _create_name : '').trim().slice(0, CRM_LABEL_MAX);
|
|
538
|
+
if (!name) errors['company.create.name'] = 'A name is required';
|
|
539
|
+
let domain = null;
|
|
540
|
+
if (create.domain !== undefined && create.domain !== null) {
|
|
541
|
+
const rawDomain = String(create.domain).trim();
|
|
542
|
+
domain = rawDomain ? normalizeCompanyDomain(rawDomain) : null;
|
|
543
|
+
if (rawDomain && !domain) {
|
|
544
|
+
errors['company.create.domain'] = 'Must be a domain, like acme.com';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (name) plan.createCompany = {
|
|
548
|
+
name,
|
|
549
|
+
domain
|
|
550
|
+
};
|
|
551
|
+
} else {
|
|
552
|
+
errors.company = 'Must be exactly one of { "link": id } or { "create": { "name", "domain"? } }';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (body.deal !== undefined && body.deal !== null) {
|
|
557
|
+
if (!isPlainObject(body.deal)) {
|
|
558
|
+
errors.deal = 'Must be an object, or null';
|
|
559
|
+
} else {
|
|
560
|
+
var _deal_title;
|
|
561
|
+
const deal = body.deal;
|
|
562
|
+
for (const key of Object.keys(deal)){
|
|
563
|
+
if (![
|
|
564
|
+
'title',
|
|
565
|
+
'amountCents',
|
|
566
|
+
'currency',
|
|
567
|
+
'stageId'
|
|
568
|
+
].includes(key)) {
|
|
569
|
+
errors[`deal.${key}`] = 'Not writable on a deal opened here';
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const title = String((_deal_title = deal.title) != null ? _deal_title : '').trim().slice(0, CRM_TITLE_MAX);
|
|
573
|
+
if (!title) errors['deal.title'] = 'A title is required';
|
|
574
|
+
let amountCents = null;
|
|
575
|
+
if (deal.amountCents !== undefined && deal.amountCents !== null) {
|
|
576
|
+
if (typeof deal.amountCents !== 'number' || !Number.isInteger(deal.amountCents) || deal.amountCents < 0) {
|
|
577
|
+
errors['deal.amountCents'] = 'Must be a whole number of cents, 0 or more';
|
|
578
|
+
} else {
|
|
579
|
+
amountCents = deal.amountCents;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
let currency = 'usd';
|
|
583
|
+
if (deal.currency !== undefined && deal.currency !== null) {
|
|
584
|
+
const value = String(deal.currency).trim().toLowerCase();
|
|
585
|
+
if (CURRENCY_PATTERN.test(value)) currency = value;
|
|
586
|
+
else errors['deal.currency'] = 'Must be a three-letter ISO 4217 code, like usd';
|
|
587
|
+
}
|
|
588
|
+
let stageId;
|
|
589
|
+
if (deal.stageId !== undefined && deal.stageId !== null) {
|
|
590
|
+
if (typeof deal.stageId !== 'string' || !deal.stageId.trim()) {
|
|
591
|
+
errors['deal.stageId'] = 'Must be a stage id';
|
|
592
|
+
} else {
|
|
593
|
+
stageId = deal.stageId.trim().slice(0, CRM_ID_MAX);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (title) plan.deal = _extends({
|
|
597
|
+
title,
|
|
598
|
+
amountCents,
|
|
599
|
+
currency
|
|
600
|
+
}, stageId ? {
|
|
601
|
+
stageId
|
|
602
|
+
} : {});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return Object.keys(errors).length ? {
|
|
606
|
+
errors
|
|
607
|
+
} : {
|
|
608
|
+
plan
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
/** `POST /v1/leads/{id}/convert`. */ async function convertLead(request, ctx, url, leadId) {
|
|
612
|
+
const body = await readJsonBody(request);
|
|
613
|
+
const parsed = readConvertInput(body);
|
|
614
|
+
if ('errors' in parsed) return crmValidationFailed(ctx, 'conversion', parsed.errors);
|
|
615
|
+
const site = readLeadSite(ctx, url, body);
|
|
616
|
+
if ('response' in site) return site.response;
|
|
617
|
+
const errors = {};
|
|
618
|
+
const ownerUid = await readOwner(ctx, body, errors);
|
|
619
|
+
if (ownerUid) Object.assign(errors, await memberError(ctx, 'ownerUid', ownerUid));
|
|
620
|
+
if (Object.keys(errors).length) return crmValidationFailed(ctx, 'conversion', errors);
|
|
621
|
+
const ref = leadsCollection(ctx, site.siteId).doc(leadId);
|
|
622
|
+
if (!(await ref.get()).exists) {
|
|
623
|
+
return ApiErrors.notFound({
|
|
624
|
+
message: 'No such lead',
|
|
625
|
+
headers: ctx.headers
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
// Scoped to the SITE: the lead is the site's record, and its id is only
|
|
629
|
+
// unique within the site — see the module header.
|
|
630
|
+
const claimed = await claimWrite(ctx, site.siteId, request.headers.get('Idempotency-Key'), 'lead-conversions');
|
|
631
|
+
if ('replay' in claimed) return claimed.replay;
|
|
632
|
+
const { claim } = claimed;
|
|
633
|
+
try {
|
|
634
|
+
var _result_companyId, _result_dealId;
|
|
635
|
+
const result = await convertHostLead(_extends({
|
|
636
|
+
firestore: ctx.firestore,
|
|
637
|
+
hostId: site.siteId,
|
|
638
|
+
orgId: ctx.orgId,
|
|
639
|
+
org: ctx.org,
|
|
640
|
+
leadId,
|
|
641
|
+
// A key has no uid; `'api'` is the attribution every REST create
|
|
642
|
+
// stamps, and a key owns nothing — see the runtime's header. Its name
|
|
643
|
+
// is what the site's activity feed shows for the conversion.
|
|
644
|
+
actor: {
|
|
645
|
+
uid: 'api',
|
|
646
|
+
email: null,
|
|
647
|
+
kind: 'api',
|
|
648
|
+
apiKeyName: ctx.keyName
|
|
649
|
+
}
|
|
650
|
+
}, ownerUid ? {
|
|
651
|
+
ownerUid
|
|
652
|
+
} : {}, parsed.plan));
|
|
653
|
+
if (result.ok === false) {
|
|
654
|
+
await claim.release();
|
|
655
|
+
return convertRefusal(ctx, result.reason);
|
|
656
|
+
}
|
|
657
|
+
const receipt = {
|
|
658
|
+
object: 'lead_conversion',
|
|
659
|
+
id: leadId,
|
|
660
|
+
siteId: site.siteId,
|
|
661
|
+
contactId: result.contactId,
|
|
662
|
+
companyId: (_result_companyId = result.companyId) != null ? _result_companyId : null,
|
|
663
|
+
dealId: (_result_dealId = result.dealId) != null ? _result_dealId : null,
|
|
664
|
+
alreadyConverted: result.alreadyConverted,
|
|
665
|
+
lead: leadView(site.siteId, await ref.get())
|
|
666
|
+
};
|
|
667
|
+
await claim.record(200, receipt);
|
|
668
|
+
return apiJson(receipt, {
|
|
669
|
+
status: result.alreadyConverted ? 200 : 201,
|
|
670
|
+
headers: ctx.headers
|
|
671
|
+
});
|
|
672
|
+
} catch (error) {
|
|
673
|
+
await claim.release();
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Each refusal the writes can answer, in the envelope this API publishes.
|
|
679
|
+
* A band is `plan_required` with the creates' own code, because on the one
|
|
680
|
+
* plan that refuses an upgrade is the whole remedy; the rest are states of
|
|
681
|
+
* the record, which is what `409 conflict` means here.
|
|
682
|
+
*/ function convertRefusal(ctx, reason) {
|
|
683
|
+
switch(reason){
|
|
684
|
+
case 'unknown-lead':
|
|
685
|
+
return ApiErrors.notFound({
|
|
686
|
+
message: 'No such lead',
|
|
687
|
+
headers: ctx.headers
|
|
688
|
+
});
|
|
689
|
+
case 'unknown-company':
|
|
690
|
+
return crmValidationFailed(ctx, 'conversion', {
|
|
691
|
+
'company.link': 'No such company in this organization'
|
|
692
|
+
});
|
|
693
|
+
case 'band-full':
|
|
694
|
+
return ApiErrors.planRequired({
|
|
695
|
+
message: 'CRM records limit reached across contacts, companies and deals — ' + 'the lead was not converted. Upgrade the plan to add more.',
|
|
696
|
+
code: 'crm_records_quota',
|
|
697
|
+
headers: ctx.headers
|
|
698
|
+
});
|
|
699
|
+
case 'no-email':
|
|
700
|
+
return ApiErrors.conflict({
|
|
701
|
+
message: 'This lead has no usable email address to convert',
|
|
702
|
+
code: 'lead_not_convertible',
|
|
703
|
+
headers: ctx.headers
|
|
704
|
+
});
|
|
705
|
+
case 'contact-not-created':
|
|
706
|
+
return ApiErrors.conflict({
|
|
707
|
+
message: 'The contact could not be created — the audience band may be full. ' + 'Nothing was changed.',
|
|
708
|
+
code: 'contact_not_created',
|
|
709
|
+
headers: ctx.headers
|
|
710
|
+
});
|
|
711
|
+
case 'erased':
|
|
712
|
+
return ApiErrors.conflict({
|
|
713
|
+
message: 'This person was erased from the organization at their request, so ' + 'a contact cannot be created for this address. Nothing was changed.',
|
|
714
|
+
code: 'person_erased',
|
|
715
|
+
headers: ctx.headers
|
|
716
|
+
});
|
|
717
|
+
case 'no-stages':
|
|
718
|
+
return ApiErrors.conflict({
|
|
719
|
+
message: 'The default pipeline has no stages to open a deal in',
|
|
720
|
+
code: 'pipeline_has_no_stages',
|
|
721
|
+
headers: ctx.headers
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// ── List ────────────────────────────────────────────────────────────────────
|
|
726
|
+
/** The `lastSeen`-ordered cursor: `ms|id`. */ function encodeLeadCursor(lastSeenAtMs, id) {
|
|
727
|
+
return `${typeof lastSeenAtMs === 'number' ? lastSeenAtMs : 0}|${id}`;
|
|
728
|
+
}
|
|
729
|
+
function decodeLeadCursor(raw) {
|
|
730
|
+
if (!raw) return null;
|
|
731
|
+
const match = /^(\d+)\|(.+)$/.exec(raw);
|
|
732
|
+
return match ? {
|
|
733
|
+
lastSeenAtMs: Number(match[1]),
|
|
734
|
+
id: match[2]
|
|
735
|
+
} : null;
|
|
736
|
+
}
|
|
737
|
+
/** `GET /v1/leads`. */ async function listLeads(ctx, url) {
|
|
738
|
+
var _url_searchParams_get, _url_searchParams_get1;
|
|
739
|
+
const site = readLeadSite(ctx, url);
|
|
740
|
+
if ('response' in site) return site.response;
|
|
741
|
+
const rawStatus = ((_url_searchParams_get = url.searchParams.get('status')) != null ? _url_searchParams_get : '').trim();
|
|
742
|
+
if (rawStatus && !CRM_LEAD_STATUSES.includes(rawStatus)) {
|
|
743
|
+
return crmValidationFailed(ctx, 'lead filter', {
|
|
744
|
+
status: `Must be one of: ${CRM_LEAD_STATUSES.join(', ')}`
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const status = rawStatus;
|
|
748
|
+
const ownerUid = ((_url_searchParams_get1 = url.searchParams.get('ownerUid')) != null ? _url_searchParams_get1 : '').trim().slice(0, CRM_ID_MAX);
|
|
749
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
750
|
+
let query = leadsVisibleTo(ctx, site.siteId).orderBy('lastSeenAtMs', 'desc').orderBy(FieldPath.documentId(), 'desc').limit(limit + 1);
|
|
751
|
+
const cursor = decodeLeadCursor(decodeCursor(url.searchParams.get('cursor')));
|
|
752
|
+
if (cursor) query = query.startAfter(cursor.lastSeenAtMs, cursor.id);
|
|
753
|
+
const snap = await query.get();
|
|
754
|
+
const docs = snap.docs.slice(0, limit);
|
|
755
|
+
const last = docs[docs.length - 1];
|
|
756
|
+
const nextCursor = snap.docs.length > limit && last ? encodeCursor(encodeLeadCursor(last.get('lastSeenAtMs'), last.id)) : null;
|
|
757
|
+
const page = docs.filter((doc)=>{
|
|
758
|
+
var _doc_data;
|
|
759
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
760
|
+
if (status && crmLeadStatus(data) !== status) return false;
|
|
761
|
+
if (ownerUid && data.ownerUid !== ownerUid) return false;
|
|
762
|
+
return true;
|
|
763
|
+
});
|
|
764
|
+
return listResponse(page.map((doc)=>leadView(site.siteId, doc)), nextCursor, ctx.headers);
|
|
765
|
+
}
|
|
766
|
+
// ── Dispatch ────────────────────────────────────────────────────────────────
|
|
767
|
+
export async function handleLeads(request, ctx, segments, url) {
|
|
768
|
+
const [, leadId, action] = segments;
|
|
769
|
+
if (!leadId) {
|
|
770
|
+
if (request.method === 'GET') {
|
|
771
|
+
const denied = requireScope(ctx, 'crm:read');
|
|
772
|
+
if (denied) return denied;
|
|
773
|
+
return listLeads(ctx, url);
|
|
774
|
+
}
|
|
775
|
+
if (request.method === 'POST') {
|
|
776
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
777
|
+
if (denied) return denied;
|
|
778
|
+
return createLead(request, ctx, url);
|
|
779
|
+
}
|
|
780
|
+
return ApiErrors.methodNotAllowed({
|
|
781
|
+
headers: _extends({}, ctx.headers, {
|
|
782
|
+
Allow: 'GET, POST'
|
|
783
|
+
})
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
if (action === 'convert') {
|
|
787
|
+
if (request.method === 'POST') {
|
|
788
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
789
|
+
if (denied) return denied;
|
|
790
|
+
return convertLead(request, ctx, url, leadId);
|
|
791
|
+
}
|
|
792
|
+
return ApiErrors.methodNotAllowed({
|
|
793
|
+
headers: _extends({}, ctx.headers, {
|
|
794
|
+
Allow: 'POST'
|
|
795
|
+
})
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
if (action !== undefined) {
|
|
799
|
+
return ApiErrors.notFound({
|
|
800
|
+
message: `Unknown endpoint: /v1/${segments.join('/')}`,
|
|
801
|
+
headers: ctx.headers
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (request.method === 'GET') {
|
|
805
|
+
const denied = requireScope(ctx, 'crm:read');
|
|
806
|
+
if (denied) return denied;
|
|
807
|
+
const site = readLeadSite(ctx, url);
|
|
808
|
+
if ('response' in site) return site.response;
|
|
809
|
+
const snap = await leadsCollection(ctx, site.siteId).doc(leadId).get();
|
|
810
|
+
if (!snap.exists) {
|
|
811
|
+
return ApiErrors.notFound({
|
|
812
|
+
message: 'No such lead',
|
|
813
|
+
headers: ctx.headers
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
return apiJson(leadView(site.siteId, snap), {
|
|
817
|
+
headers: ctx.headers
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
if (request.method === 'PATCH') {
|
|
821
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
822
|
+
if (denied) return denied;
|
|
823
|
+
return updateLead(request, ctx, url, leadId);
|
|
824
|
+
}
|
|
825
|
+
return ApiErrors.methodNotAllowed({
|
|
826
|
+
headers: _extends({}, ctx.headers, {
|
|
827
|
+
Allow: 'GET, PATCH'
|
|
828
|
+
})
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
//# sourceMappingURL=crm-leads.js.map
|