@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,1076 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { nameSearchFields } from "@aglyn/aglyn/app-utils/name-search";
|
|
18
|
+
import { CAPTURED_BY_HOST_FIELD, CONTACT_COMPANY_IDS_FIELD, CONTACT_FACETS_FIELD, CONTACT_FIELDS_MAX_PER_ORG, CONTACT_LIFECYCLE_STAGES, consentGroupForHost, contactFacetPath, createResourceUid, CRM_COLLECTIONS, CRM_MEDIA_IDS_MAX, isContactLifecycleStage, MARKETING_CONSENT_BY_HOST_FIELD, marketingConsentFieldsForGroup, marketingConsentHostIds, normalizeAddress, normalizeContactEmail, normalizeCrmMediaIds, normalizePhone, ORG_SCOPE_TOKEN, planContactCompanyLink, readContactCompanyLink, readContactCustomInput, readContactFacet, soloConsentGroup } from "@aglyn/aglyn/server";
|
|
19
|
+
import { ApiErrors, apiJson, contactCompanyMirrorValue, crmRecordsQuotaForOrg, listResponse, restampCrmListFieldsAt, settleCompanyContactsCounts } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { claimWrite, orgOwnsHost, paginate, readJsonBody, requireScope, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
21
|
+
// The leaf, so a spec that stands a partial barrel in still reaches it.
|
|
22
|
+
import { scheduleCapturedEmailCheck } from "@aglyn/tenant-data-admin/server/capture-email-check";
|
|
23
|
+
// The leaf, not the barrel: the console's API specs substitute the barrel
|
|
24
|
+
// wholesale, and the lookup must reach the real index logic under them.
|
|
25
|
+
import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
|
|
26
|
+
// The leaf for the same reason: the refusals a deleted contact held must be
|
|
27
|
+
// kept by the real store under a spec's barrel (AGL-3338).
|
|
28
|
+
import { deleteWholeContact, removeContactKeepingRefusals } from "@aglyn/tenant-data-admin/server/retained-refusals";
|
|
29
|
+
import { FieldPath, Timestamp } from "firebase-admin/firestore";
|
|
30
|
+
import { mergeContactRoute } from "./contacts-merge.js";
|
|
31
|
+
import { CRM_ID_MAX, CRM_LABEL_MAX, createPayload, crmRefErrors, memberError, readChoice, readOptionalText, readRefId, updatePayload } from "./crm-shared.js";
|
|
32
|
+
/**
|
|
33
|
+
* `/v1/contacts` (AGL-618, AGL-2276, AGL-2606): the organization's people —
|
|
34
|
+
* list, read, create, update, delete, and `POST /v1/contacts/{id}/merge` —
|
|
35
|
+
* served by the CRM, which keeps them. The pipeline in front of it (the key,
|
|
36
|
+
* the plan's API access, the quota, the rate limit, the envelope) is the
|
|
37
|
+
* console router's; the CRM suite's plan refusal is declared with the
|
|
38
|
+
* registration (`declarations.console-server.ts`).
|
|
39
|
+
*/ // ── Contacts (read) ─────────────────────────────────────────────────────────
|
|
40
|
+
/**
|
|
41
|
+
* The contact object as published.
|
|
42
|
+
*
|
|
43
|
+
* `notes` and `marketingConsent` are here because `PATCH` writes them
|
|
44
|
+
* (AGL-2276). A projection that omits a field the same resource accepts is
|
|
45
|
+
* the shape that has shipped broken before: the client writes, reads back,
|
|
46
|
+
* sees nothing, and cannot tell a dropped write from a narrow view. Every
|
|
47
|
+
* writable field appears here, and `contact-writes.spec.ts` asserts that as a
|
|
48
|
+
* property of the pair rather than field by field.
|
|
49
|
+
*
|
|
50
|
+
* `email`, `sources` and `interactions` are read-only and stay that way —
|
|
51
|
+
* `email` is the dedupe key the whole CRM unifies on, and the other two are
|
|
52
|
+
* provenance. `interactions` is not published at all; the console's timeline
|
|
53
|
+
* is not part of the API contract.
|
|
54
|
+
*/ /**
|
|
55
|
+
* The CRM profile a contact carries (AGL-2606): the six fields a sales team
|
|
56
|
+
* keeps on a person, read from the FACETS because that is where the console
|
|
57
|
+
* keeps them — per holder, under `facets.{groupId}` (`ContactFacet`), so two
|
|
58
|
+
* unrelated businesses sharing one row never read each other's knowledge of
|
|
59
|
+
* the person.
|
|
60
|
+
*
|
|
61
|
+
* An API key is an organization credential, and every facet on the row is
|
|
62
|
+
* the organization's own; so with no site named the profile is the UNION of
|
|
63
|
+
* the holders — each field from the first holder, in stable id order, that
|
|
64
|
+
* has set it. That is the right read for the caller a key represents (an
|
|
65
|
+
* integration acting for the account) and the only one that does not force
|
|
66
|
+
* every read to name a site. Naming one (`?consentSiteId=`) reads that
|
|
67
|
+
* site's group alone, which is what a per-brand sync wants and what a PATCH
|
|
68
|
+
* that just wrote through that site reads back.
|
|
69
|
+
*
|
|
70
|
+
* `null` for every unset field rather than an absent key, so a client can
|
|
71
|
+
* tell "no phone" from "this API does not publish phones".
|
|
72
|
+
*/ const CONTACT_CRM_FIELDS = [
|
|
73
|
+
'phone',
|
|
74
|
+
'jobTitle',
|
|
75
|
+
'companyId',
|
|
76
|
+
'address',
|
|
77
|
+
'ownerUid',
|
|
78
|
+
'lifecycleStage',
|
|
79
|
+
// Files are a facet field like the rest (AGL-2662): an agency running two
|
|
80
|
+
// client brands has one contact document between them, and a contract one
|
|
81
|
+
// client filed is not the other client's to read.
|
|
82
|
+
'mediaIds'
|
|
83
|
+
];
|
|
84
|
+
/** The groups holding a facet on this row, in stable order. */ function contactFacetHolders(data) {
|
|
85
|
+
const facets = data[CONTACT_FACETS_FIELD];
|
|
86
|
+
return facets && typeof facets === 'object' && !Array.isArray(facets) ? Object.keys(facets).sort() : [];
|
|
87
|
+
}
|
|
88
|
+
function contactCrmProfile(data, groupId) {
|
|
89
|
+
const profile = {
|
|
90
|
+
phone: null,
|
|
91
|
+
jobTitle: null,
|
|
92
|
+
companyId: null,
|
|
93
|
+
address: null,
|
|
94
|
+
ownerUid: null,
|
|
95
|
+
lifecycleStage: null,
|
|
96
|
+
// An empty ARRAY rather than null, so a client can index it without a
|
|
97
|
+
// guard — the same shape `custom` publishes for the same reason.
|
|
98
|
+
mediaIds: []
|
|
99
|
+
};
|
|
100
|
+
for (const holder of groupId ? [
|
|
101
|
+
groupId
|
|
102
|
+
] : contactFacetHolders(data)){
|
|
103
|
+
const facet = readContactFacet(data, holder);
|
|
104
|
+
for (const field of CONTACT_CRM_FIELDS){
|
|
105
|
+
const value = facet[field];
|
|
106
|
+
if (field === 'mediaIds') {
|
|
107
|
+
// The union of every holder's attachments when no site was named,
|
|
108
|
+
// and one holder's when one was — the same rule the scalar fields
|
|
109
|
+
// follow, spelled apart because a list merges rather than wins.
|
|
110
|
+
const held = profile[field];
|
|
111
|
+
if (!held.length) profile[field] = normalizeCrmMediaIds(value);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (profile[field] === null && value !== undefined && value !== null) {
|
|
115
|
+
profile[field] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return profile;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* @param groupId - the holder whose CRM profile to publish, or `null` for
|
|
123
|
+
* the union — see {@link contactCrmProfile}.
|
|
124
|
+
*/ function contactView(doc, groupId = null) {
|
|
125
|
+
var _doc_data, _data_email, _data_name, _data_tags, _data_notes, _serialize, _serialize1;
|
|
126
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
127
|
+
return _extends({
|
|
128
|
+
id: doc.id,
|
|
129
|
+
object: 'contact',
|
|
130
|
+
email: (_data_email = data.email) != null ? _data_email : null,
|
|
131
|
+
name: (_data_name = data.name) != null ? _data_name : null,
|
|
132
|
+
tags: (_data_tags = data.tags) != null ? _data_tags : [],
|
|
133
|
+
notes: (_data_notes = data.notes) != null ? _data_notes : null
|
|
134
|
+
}, contactCrmProfile(data, groupId), {
|
|
135
|
+
// Every company any holder has filed the person under — the top-level
|
|
136
|
+
// twin of the facets' `companyId`, and what `?companyId=` queries.
|
|
137
|
+
companyIds: Array.isArray(data.companyIds) ? data.companyIds.filter((id)=>typeof id === 'string') : [],
|
|
138
|
+
// The other addresses a merge folded into this record (AGL-2625) —
|
|
139
|
+
// identity like `email`, and read-only for the same reason.
|
|
140
|
+
alternateEmails: Array.isArray(data.alternateEmails) ? data.alternateEmails.filter((email)=>typeof email === 'string') : [],
|
|
141
|
+
/*
|
|
142
|
+
* TRUE means "some site may mail this person", and `consentSites` says
|
|
143
|
+
* which. A single boolean is what the org-wide model published, and it is
|
|
144
|
+
* exactly the claim that turned out to be wrong: an agency's key read
|
|
145
|
+
* `true` and could not tell which of its brands the person had agreed to
|
|
146
|
+
* hear from. Both are published so a client can act on either.
|
|
147
|
+
*/ marketingConsent: data.marketingConsent === false ? false : marketingConsentHostIds(data).length > 0,
|
|
148
|
+
consentSites: marketingConsentHostIds(data),
|
|
149
|
+
sources: data.sources ? Object.keys(data.sources) : [],
|
|
150
|
+
/*
|
|
151
|
+
* The org's custom field values, keyed by field key (AGL-2601). The same
|
|
152
|
+
* top-level map `tags` and `notes` beside it are read from — this resource
|
|
153
|
+
* is the ORGANIZATION's view, and its writes land there. An empty object
|
|
154
|
+
* rather than `null`, so a client can index it without a guard.
|
|
155
|
+
*/ custom: data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom) ? data.custom : {},
|
|
156
|
+
created: (_serialize = serialize(data.createdAt)) != null ? _serialize : null,
|
|
157
|
+
updated: (_serialize1 = serialize(data.updatedAt)) != null ? _serialize1 : null
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The org's custom field definitions, retired ones included, for the API's
|
|
162
|
+
* `custom` validation (AGL-2601).
|
|
163
|
+
*
|
|
164
|
+
* One bounded read of a small collection, paid only when a body carries
|
|
165
|
+
* `custom` — the reader is called from the two writers, never from the view.
|
|
166
|
+
* Retired definitions come back too because `readContactCustomInput` refuses
|
|
167
|
+
* a write under one BY NAME, which is a better answer than "no such field"
|
|
168
|
+
* for a key the integration wrote last month.
|
|
169
|
+
*/ async function readOrgContactFieldDefinitions(ctx) {
|
|
170
|
+
const snapshot = await ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.contactFields).orderBy(FieldPath.documentId()).limit(CONTACT_FIELDS_MAX_PER_ORG).get();
|
|
171
|
+
return snapshot.docs.map((doc)=>doc.data());
|
|
172
|
+
}
|
|
173
|
+
const CONTACT_NAME_MAX = 120;
|
|
174
|
+
const CONTACT_NOTES_MAX = 2000;
|
|
175
|
+
const CONTACT_TAGS_MAX = 50;
|
|
176
|
+
const CONTACT_TAG_MAX = 60;
|
|
177
|
+
/** Fields a client may send. Anything else is named, never silently dropped. */ const CONTACT_WRITABLE = [
|
|
178
|
+
'name',
|
|
179
|
+
'tags',
|
|
180
|
+
'notes',
|
|
181
|
+
'marketingConsent',
|
|
182
|
+
'consentSiteId',
|
|
183
|
+
'consentGroupId',
|
|
184
|
+
'custom'
|
|
185
|
+
];
|
|
186
|
+
/**
|
|
187
|
+
* The sites an API opt-in is recorded for (AGL-3320): the named site's whole
|
|
188
|
+
* consent group only when the body ECHOED that group's id, and the site
|
|
189
|
+
* alone otherwise.
|
|
190
|
+
*
|
|
191
|
+
* An integration's own signup form is a capture surface this platform never
|
|
192
|
+
* sees, so it cannot prove which sentence it showed the way a Form block
|
|
193
|
+
* does. Echoing the group's id is the integration saying it showed the
|
|
194
|
+
* group's name; an opt-in that says nothing about the group, or names one
|
|
195
|
+
* the site is no longer in, is the one site's — narrow, which is the safe
|
|
196
|
+
* way to be wrong, and visible in the response's `consentSites`.
|
|
197
|
+
*/ function apiGrantGroup(ctx, siteId, echoedGroupId) {
|
|
198
|
+
const group = consentGroupForHost(ctx.org, siteId);
|
|
199
|
+
return group.declared && echoedGroupId === group.groupId ? group : soloConsentGroup(siteId);
|
|
200
|
+
}
|
|
201
|
+
function readContactInput(body, { partial }) {
|
|
202
|
+
const errors = {};
|
|
203
|
+
const values = {
|
|
204
|
+
crm: {}
|
|
205
|
+
};
|
|
206
|
+
const allowed = new Set([
|
|
207
|
+
...CONTACT_WRITABLE,
|
|
208
|
+
...CONTACT_CRM_FIELDS,
|
|
209
|
+
...partial ? [] : [
|
|
210
|
+
'email'
|
|
211
|
+
]
|
|
212
|
+
]);
|
|
213
|
+
for (const key of Object.keys(body)){
|
|
214
|
+
if (allowed.has(key)) continue;
|
|
215
|
+
errors[key] = key === 'email' ? 'Not writable — a contact is identified by its email' : key === 'sources' || key === 'interactions' ? 'Not writable — set by the capture point that recorded it' : 'Not writable on a contact';
|
|
216
|
+
}
|
|
217
|
+
if (!partial) {
|
|
218
|
+
// The SAME normalizer every capture point uses. Re-implementing the
|
|
219
|
+
// check here would let the API accept an address `upsertHostContact`
|
|
220
|
+
// would reject, and the two would then disagree about who is a duplicate.
|
|
221
|
+
const email = normalizeContactEmail(body.email);
|
|
222
|
+
if (!email) errors.email = 'A valid email address is required';
|
|
223
|
+
else values.email = email;
|
|
224
|
+
}
|
|
225
|
+
if (body.name !== undefined) {
|
|
226
|
+
var _body_name;
|
|
227
|
+
const name = String((_body_name = body.name) != null ? _body_name : '').trim().slice(0, CONTACT_NAME_MAX);
|
|
228
|
+
if (name) values.name = name;
|
|
229
|
+
else errors.name = 'Must not be empty';
|
|
230
|
+
}
|
|
231
|
+
if (body.tags !== undefined) {
|
|
232
|
+
if (!Array.isArray(body.tags)) {
|
|
233
|
+
errors.tags = 'Must be an array of strings';
|
|
234
|
+
} else {
|
|
235
|
+
// An EMPTY array is legal and means "clear the tags" — the console's
|
|
236
|
+
// own tag editor can empty the field, and an API that could only ever
|
|
237
|
+
// add tags would leave an integration unable to undo its own mistake.
|
|
238
|
+
values.tags = body.tags.map((tag)=>String(tag).trim().slice(0, CONTACT_TAG_MAX)).filter((tag)=>tag.length > 0).slice(0, CONTACT_TAGS_MAX);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (body.notes !== undefined) {
|
|
242
|
+
var _body_notes;
|
|
243
|
+
values.notes = String((_body_notes = body.notes) != null ? _body_notes : '').slice(0, CONTACT_NOTES_MAX);
|
|
244
|
+
}
|
|
245
|
+
if (body.consentSiteId !== undefined) {
|
|
246
|
+
var _body_consentSiteId;
|
|
247
|
+
const siteId = String((_body_consentSiteId = body.consentSiteId) != null ? _body_consentSiteId : '').trim();
|
|
248
|
+
if (!siteId) errors.consentSiteId = 'Must name a site';
|
|
249
|
+
else values.consentSiteId = siteId;
|
|
250
|
+
}
|
|
251
|
+
if (body.consentGroupId !== undefined) {
|
|
252
|
+
const groupId = typeof body.consentGroupId === 'string' ? body.consentGroupId.trim() : '';
|
|
253
|
+
if (!groupId) errors.consentGroupId = 'Must name a consent group';
|
|
254
|
+
else values.consentGroupId = groupId;
|
|
255
|
+
}
|
|
256
|
+
if (body.marketingConsent !== undefined) {
|
|
257
|
+
if (typeof body.marketingConsent !== 'boolean') {
|
|
258
|
+
errors.marketingConsent = 'Must be true or false';
|
|
259
|
+
} else {
|
|
260
|
+
values.marketingConsent = body.marketingConsent;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (body.custom !== undefined) {
|
|
264
|
+
if (!body.custom || typeof body.custom !== 'object' || Array.isArray(body.custom)) {
|
|
265
|
+
errors.custom = 'Must be an object of field values keyed by field key';
|
|
266
|
+
} else {
|
|
267
|
+
values.custom = body.custom;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/*
|
|
271
|
+
* The CRM profile (AGL-2606). Each value runs through the normalizer the
|
|
272
|
+
* console writes with — `normalizePhone`, `normalizeAddress`, the lifecycle
|
|
273
|
+
* list — so the API cannot store a phone the console would refuse, or an
|
|
274
|
+
* address of empty strings the console would read as "has an address". A
|
|
275
|
+
* value that does not survive names the field rather than storing the raw
|
|
276
|
+
* string: a half-normalized phone is the unusable number `normalizePhone`
|
|
277
|
+
* exists to end. The readers are `crm-shared`'s, so a contact and a company
|
|
278
|
+
* bound their text the same way. `ownerUid` and `companyId` are checked for
|
|
279
|
+
* existence by the caller, after this synchronous pass, so a body already
|
|
280
|
+
* refused never spends the reads.
|
|
281
|
+
*/ const crm = {};
|
|
282
|
+
const phone = readOptionalText(body, 'phone', CRM_LABEL_MAX, errors);
|
|
283
|
+
if (phone === null) {
|
|
284
|
+
crm.phone = null;
|
|
285
|
+
} else if (phone !== undefined) {
|
|
286
|
+
const normalized = normalizePhone(phone);
|
|
287
|
+
if (normalized) crm.phone = normalized;
|
|
288
|
+
else errors.phone = 'Must be a phone number with a country code, like +15125550123';
|
|
289
|
+
}
|
|
290
|
+
const jobTitle = readOptionalText(body, 'jobTitle', CONTACT_NAME_MAX, errors);
|
|
291
|
+
if (jobTitle !== undefined) crm.jobTitle = jobTitle;
|
|
292
|
+
const companyId = readRefId(body, 'companyId', errors);
|
|
293
|
+
if (companyId !== undefined) crm.companyId = companyId;
|
|
294
|
+
if (body.address !== undefined) {
|
|
295
|
+
if (body.address === null) {
|
|
296
|
+
crm.address = null;
|
|
297
|
+
} else if (typeof body.address !== 'object' || Array.isArray(body.address)) {
|
|
298
|
+
errors.address = 'Must be an address object';
|
|
299
|
+
} else {
|
|
300
|
+
// A blank address normalizes to `null`, which clears.
|
|
301
|
+
crm.address = normalizeAddress(body.address);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const ownerUid = readOptionalText(body, 'ownerUid', CONTACT_NAME_MAX, errors);
|
|
305
|
+
if (ownerUid !== undefined) crm.ownerUid = ownerUid;
|
|
306
|
+
if (body.mediaIds !== undefined) {
|
|
307
|
+
if (!Array.isArray(body.mediaIds)) {
|
|
308
|
+
errors.mediaIds = 'Must be an array of media ids';
|
|
309
|
+
} else if (body.mediaIds.length > CRM_MEDIA_IDS_MAX) {
|
|
310
|
+
errors.mediaIds = `At most ${CRM_MEDIA_IDS_MAX} files may be attached`;
|
|
311
|
+
} else {
|
|
312
|
+
// The SAME normalizer the console card writes through. An empty array
|
|
313
|
+
// is legal and clears this holder's attachments.
|
|
314
|
+
crm.mediaIds = normalizeCrmMediaIds(body.mediaIds);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (body.lifecycleStage === null) {
|
|
318
|
+
crm.lifecycleStage = null;
|
|
319
|
+
} else {
|
|
320
|
+
const stage = readChoice(body, 'lifecycleStage', CONTACT_LIFECYCLE_STAGES, errors);
|
|
321
|
+
if (stage) crm.lifecycleStage = stage;
|
|
322
|
+
}
|
|
323
|
+
values.crm = crm;
|
|
324
|
+
// Whether the body MEANT to write a profile — judged by the keys it sent,
|
|
325
|
+
// not by which of them parsed, so a body whose every profile value was
|
|
326
|
+
// refused is told about those values and not also that its site was
|
|
327
|
+
// unwelcome.
|
|
328
|
+
const writesCrm = CONTACT_CRM_FIELDS.some((field)=>body[field] !== undefined);
|
|
329
|
+
/*
|
|
330
|
+
* AN OPT-IN MUST NAME THE SITE IT WAS GIVEN TO; A REFUSAL MUST NOT.
|
|
331
|
+
*
|
|
332
|
+
* An API key belongs to an ORGANIZATION, and an organization is not a
|
|
333
|
+
* controller — an agency's key reaches every client brand it runs. So an
|
|
334
|
+
* integration asserting that somebody opted in has to say which brand they
|
|
335
|
+
* opted in to, exactly as a form does by being served from one site. There
|
|
336
|
+
* is no safe default: picking the org's only site works until the org has
|
|
337
|
+
* two, and picking none is the org-wide grant this field exists to stop.
|
|
338
|
+
*
|
|
339
|
+
* A refusal is the mirror image and is refused a site on purpose. It
|
|
340
|
+
* applies to every brand in the account, which is what `readMarketingBasis`
|
|
341
|
+
* does with an unscoped `false`, and accepting a site alongside it would
|
|
342
|
+
* imply a per-brand opt-out this endpoint does not write.
|
|
343
|
+
*/ if (values.marketingConsent === true && !values.consentSiteId) {
|
|
344
|
+
errors.consentSiteId = 'Required with marketingConsent: true — name the site the person opted in to';
|
|
345
|
+
}
|
|
346
|
+
/*
|
|
347
|
+
* A CRM PROFILE FIELD NAMES THE SITE TOO (AGL-2606), for the facet's
|
|
348
|
+
* reason: the profile is one holder's knowledge of the person and lives
|
|
349
|
+
* under that holder's group, so a write has to say whose. The same
|
|
350
|
+
* parameter, because it is the same question — which of the organization's
|
|
351
|
+
* sites is this write made on behalf of — and an integrator should not
|
|
352
|
+
* learn two names for it. Beside a refusal the site is accepted, since the
|
|
353
|
+
* refusal still applies to every site and the site now names the facet.
|
|
354
|
+
*/ if (writesCrm && !values.consentSiteId) {
|
|
355
|
+
errors.consentSiteId = 'Required with a CRM profile field — name the site whose profile of this person to write';
|
|
356
|
+
}
|
|
357
|
+
if (values.marketingConsent === false && values.consentSiteId && !writesCrm) {
|
|
358
|
+
errors.consentSiteId = 'Not accepted with marketingConsent: false — a refusal applies to every site';
|
|
359
|
+
}
|
|
360
|
+
if (values.consentSiteId && values.marketingConsent === undefined && !writesCrm) {
|
|
361
|
+
errors.consentSiteId = 'Only accepted alongside marketingConsent or a CRM profile field';
|
|
362
|
+
}
|
|
363
|
+
/*
|
|
364
|
+
* THE GROUP IS ECHOED ONLY BESIDE AN OPT-IN (AGL-3320). It says which
|
|
365
|
+
* disclosure the person saw, so it widens a grant to the site's consent
|
|
366
|
+
* group and means nothing anywhere else — least of all beside a refusal,
|
|
367
|
+
* which already stands against every site.
|
|
368
|
+
*/ if (values.consentGroupId && values.marketingConsent !== true) {
|
|
369
|
+
errors.consentGroupId = 'Only accepted with marketingConsent: true — name the consent group the person was shown';
|
|
370
|
+
}
|
|
371
|
+
return Object.keys(errors).length ? {
|
|
372
|
+
errors
|
|
373
|
+
} : {
|
|
374
|
+
values
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The two references a contact's CRM profile can carry, checked for
|
|
379
|
+
* existence — the SAME checks a deal or a task makes on its own `ownerUid`
|
|
380
|
+
* and `companyId`, so a contact cannot point at a company `/v1/deals` would
|
|
381
|
+
* refuse. Run after the synchronous grammar, so a body already refused never
|
|
382
|
+
* spends the reads.
|
|
383
|
+
*/ async function contactCrmRefErrors(ctx, crm) {
|
|
384
|
+
var _crm_companyId;
|
|
385
|
+
const [owner, refs] = await Promise.all([
|
|
386
|
+
memberError(ctx, 'ownerUid', crm.ownerUid),
|
|
387
|
+
crmRefErrors(ctx, {
|
|
388
|
+
companyId: (_crm_companyId = crm.companyId) != null ? _crm_companyId : undefined
|
|
389
|
+
})
|
|
390
|
+
]);
|
|
391
|
+
return _extends({}, owner, refs);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The facet a create writes when the body carried a CRM profile field, under
|
|
395
|
+
* the group of the site the write named — filed as `upsertHostContact` files
|
|
396
|
+
* a capture — plus the top-level `companyIds` the company filter queries.
|
|
397
|
+
*
|
|
398
|
+
* A NESTED object rather than dotted paths, because this is a `create()` and
|
|
399
|
+
* there is no other holder's facet on the row to clobber yet.
|
|
400
|
+
* {@link contactCrmUpdateFields} writes the same fields as paths for the
|
|
401
|
+
* opposite reason.
|
|
402
|
+
*/ function contactCrmCreateFields(ctx, crm, siteId) {
|
|
403
|
+
const stored = createPayload(crm);
|
|
404
|
+
if (Object.keys(stored).length === 0) return {};
|
|
405
|
+
const group = consentGroupForHost(ctx.org, siteId);
|
|
406
|
+
return _extends({
|
|
407
|
+
[CONTACT_FACETS_FIELD]: {
|
|
408
|
+
[group.groupId]: _extends({
|
|
409
|
+
sources: {
|
|
410
|
+
api: true
|
|
411
|
+
},
|
|
412
|
+
interactions: []
|
|
413
|
+
}, stored)
|
|
414
|
+
}
|
|
415
|
+
}, crm.companyId ? {
|
|
416
|
+
companyIds: [
|
|
417
|
+
crm.companyId
|
|
418
|
+
]
|
|
419
|
+
} : {});
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The CRM profile as an `update()` payload: one dotted path per field sent,
|
|
423
|
+
* under the facet of the named site's group — `contactFacetPath`, never a
|
|
424
|
+
* nested object, because an `update` REPLACES a map it is handed whole and
|
|
425
|
+
* the nested form would delete every other holder's profile of the person.
|
|
426
|
+
* `null` becomes a field delete, so a PATCH can clear.
|
|
427
|
+
*
|
|
428
|
+
* `companyIds` is the top-level twin of the facets' `companyId`s, kept so
|
|
429
|
+
* `?companyId=` can be one indexed clause. Which ids it keeps is the CRM's
|
|
430
|
+
* one planner's decision (`planContactCompanyLink`, AGL-2613): the old id
|
|
431
|
+
* leaves only when no OTHER holder still files the person under it, an id
|
|
432
|
+
* some other surface put there is left exactly where it was, and the plan
|
|
433
|
+
* also names the companies whose contacts count the write moves — which the
|
|
434
|
+
* caller settles after the contact is written, because they are other
|
|
435
|
+
* documents.
|
|
436
|
+
*/ function contactCrmUpdateFields(ctx, data, crm, siteId) {
|
|
437
|
+
const update = {};
|
|
438
|
+
const group = consentGroupForHost(ctx.org, siteId);
|
|
439
|
+
for (const [field, value] of Object.entries(updatePayload(crm))){
|
|
440
|
+
update[contactFacetPath(group.groupId, field)] = value;
|
|
441
|
+
}
|
|
442
|
+
let link = null;
|
|
443
|
+
if (crm.companyId !== undefined) {
|
|
444
|
+
link = planContactCompanyLink(readContactCompanyLink(data, group.groupId), crm.companyId);
|
|
445
|
+
const mirror = link ? contactCompanyMirrorValue(link) : undefined;
|
|
446
|
+
if (mirror !== undefined) update[CONTACT_COMPANY_IDS_FIELD] = mirror;
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
update,
|
|
450
|
+
link
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The holder a contact READ is for, from `?consentSiteId=`: that site's
|
|
455
|
+
* consent group, or `null` for the union view. The parameter the writes take,
|
|
456
|
+
* validated the same way, so a client has one name for "which site" on every
|
|
457
|
+
* contact call.
|
|
458
|
+
*/ function contactViewGroup(ctx, url) {
|
|
459
|
+
var _url_searchParams_get;
|
|
460
|
+
const siteId = ((_url_searchParams_get = url.searchParams.get('consentSiteId')) != null ? _url_searchParams_get : '').trim();
|
|
461
|
+
if (!siteId) return {
|
|
462
|
+
groupId: null
|
|
463
|
+
};
|
|
464
|
+
if (!orgOwnsHost(ctx, siteId)) {
|
|
465
|
+
return {
|
|
466
|
+
response: ApiErrors.badRequest({
|
|
467
|
+
message: 'Contact filter failed validation',
|
|
468
|
+
code: 'validation_failed',
|
|
469
|
+
fields: {
|
|
470
|
+
consentSiteId: 'No such site in this organization'
|
|
471
|
+
},
|
|
472
|
+
headers: ctx.headers
|
|
473
|
+
})
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
groupId: consentGroupForHost(ctx.org, siteId).groupId
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const contactsCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts');
|
|
481
|
+
/** The org's companies, beside its contacts — where a link's count lands (AGL-2613). */ const companiesCollection = (ctx)=>ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.companies);
|
|
482
|
+
/**
|
|
483
|
+
* `POST /v1/contacts` (AGL-2276) — the call that lets an integration own the
|
|
484
|
+
* customer list.
|
|
485
|
+
*
|
|
486
|
+
* ## The audience band
|
|
487
|
+
*
|
|
488
|
+
* `checkContactQuota` is the gate, and it is the SAME one `upsertHostContact`
|
|
489
|
+
* applies to a form capture (AGL-890). Metered plans always create and bill
|
|
490
|
+
* the overage through the `report-usage` rollup, which counts
|
|
491
|
+
* `orgs/{orgId}/contacts` without caring who wrote them — so an API-created
|
|
492
|
+
* contact meters exactly like a captured one, and there is no unbilled door.
|
|
493
|
+
* Free hard-bands at its included count; free cannot reach `/v1` at all
|
|
494
|
+
* without a staff `features.apiAccess` override, which is precisely the shape
|
|
495
|
+
* AGL-2163 found running unbounded, so the gate is here rather than assumed
|
|
496
|
+
* unreachable.
|
|
497
|
+
*
|
|
498
|
+
* Where capture DROPS a refused contact (silently, onto a
|
|
499
|
+
* `counters/contactsDropped` the console alerts on), this refuses out loud
|
|
500
|
+
* with a `403`: a form must never fail a visitor's signup because of billing,
|
|
501
|
+
* and an API call has an operator on the other end who needs to be told.
|
|
502
|
+
*
|
|
503
|
+
* ## Duplicates
|
|
504
|
+
*
|
|
505
|
+
* A contact is unified on its normalized email, so a second create for an
|
|
506
|
+
* address already present is a `409 conflict` naming the existing id rather
|
|
507
|
+
* than a second row. Silently upserting instead would hide a real integration
|
|
508
|
+
* bug — two upstream systems both claiming to own the record — and would make
|
|
509
|
+
* `POST` and `PATCH` the same call.
|
|
510
|
+
*
|
|
511
|
+
* ## Why the claim is taken ABOVE both refusals
|
|
512
|
+
*
|
|
513
|
+
* `createRecord` and `createDataset` check their quota FIRST and claim after,
|
|
514
|
+
* so that a plan refusal never burns a key. That ordering has a hole this one
|
|
515
|
+
* deliberately does not copy, and `api-v1-contact-writes.spec.ts` is what
|
|
516
|
+
* found it: **a create that exactly fills the band cannot be retried.** The
|
|
517
|
+
* first call succeeds and consumes the last slot; the retry — same key, lost
|
|
518
|
+
* response — re-counts, is now AT the band, and gets a `403` instead of the
|
|
519
|
+
* replay `conventions.md` promises ("if the original succeeded, the same
|
|
520
|
+
* response comes back"). The integrator is left unable to tell whether the
|
|
521
|
+
* contact exists, which is the exact confusion the key exists to remove. The
|
|
522
|
+
* duplicate check has the same shape, and worse: the retry's own successful
|
|
523
|
+
* write is what makes the email a duplicate, so EVERY retry of a successful
|
|
524
|
+
* create would answer `409 contact_exists`.
|
|
525
|
+
*
|
|
526
|
+
* Claiming first and RELEASING on each refusal gets both properties at once:
|
|
527
|
+
* a settled key replays before any of this is reached, and a refusal gives
|
|
528
|
+
* the key back so the retry that should finally succeed still can. It is the
|
|
529
|
+
* ordering `deleteRecord` already argues for, and it pays the same price —
|
|
530
|
+
* taking-and-releasing on a genuine refusal. AGL-2278 applies it to the two
|
|
531
|
+
* older creates.
|
|
532
|
+
*
|
|
533
|
+
* `visibleTo` is stamped with `ORG_SCOPE_TOKEN`, as capture does. A contact
|
|
534
|
+
* written without it matches no `array-contains-any` and is therefore visible
|
|
535
|
+
* on NO site (AGL-1044) — the API would create data nobody can see, which is
|
|
536
|
+
* worse than refusing.
|
|
537
|
+
*/ async function createContact(request, ctx) {
|
|
538
|
+
// Validation stays above the claim, as `createRecord` argues: a
|
|
539
|
+
// deterministic 400 must never take the key at all, so an integrator fixes
|
|
540
|
+
// the payload and retries with the same one.
|
|
541
|
+
const parsed = readContactInput(await readJsonBody(request), {
|
|
542
|
+
partial: false
|
|
543
|
+
});
|
|
544
|
+
if ('errors' in parsed) {
|
|
545
|
+
return ApiErrors.badRequest({
|
|
546
|
+
message: 'Contact failed validation',
|
|
547
|
+
code: 'validation_failed',
|
|
548
|
+
fields: parsed.errors,
|
|
549
|
+
headers: ctx.headers
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
const { email, name, tags, notes, marketingConsent, consentSiteId, consentGroupId, crm } = parsed.values;
|
|
553
|
+
if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {
|
|
554
|
+
return ApiErrors.badRequest({
|
|
555
|
+
message: 'Contact failed validation',
|
|
556
|
+
code: 'validation_failed',
|
|
557
|
+
fields: {
|
|
558
|
+
consentSiteId: 'No such site in this organization'
|
|
559
|
+
},
|
|
560
|
+
headers: ctx.headers
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
// Judged against the org's definitions, above the claim like every other
|
|
564
|
+
// deterministic 400: an unknown key is named, never dropped (AGL-2601).
|
|
565
|
+
const custom = parsed.values.custom ? readContactCustomInput(parsed.values.custom, await readOrgContactFieldDefinitions(ctx)) : null;
|
|
566
|
+
if (custom && 'errors' in custom) {
|
|
567
|
+
return ApiErrors.badRequest({
|
|
568
|
+
message: 'Contact failed validation',
|
|
569
|
+
code: 'validation_failed',
|
|
570
|
+
fields: custom.errors,
|
|
571
|
+
headers: ctx.headers
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
// Resolved to the map itself here, above the claim, because the create
|
|
575
|
+
// below is reached through a try block the narrowing above does not
|
|
576
|
+
// survive; an empty map writes no `custom` key at all.
|
|
577
|
+
const customValues = custom && 'values' in custom ? custom.values : {};
|
|
578
|
+
// Still validation, still above the claim: a dangling `companyId` is as
|
|
579
|
+
// deterministic a 400 as a malformed one, and must not burn the key.
|
|
580
|
+
const crmErrors = await contactCrmRefErrors(ctx, crm);
|
|
581
|
+
if (Object.keys(crmErrors).length) {
|
|
582
|
+
return ApiErrors.badRequest({
|
|
583
|
+
message: 'Contact failed validation',
|
|
584
|
+
code: 'validation_failed',
|
|
585
|
+
fields: crmErrors,
|
|
586
|
+
headers: ctx.headers
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
const collection = contactsCollection(ctx);
|
|
590
|
+
const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'contacts');
|
|
591
|
+
if ('replay' in claimed) return claimed.replay;
|
|
592
|
+
const { claim } = claimed;
|
|
593
|
+
try {
|
|
594
|
+
// Through the org's address index (AGL-2633): an address a merge folded
|
|
595
|
+
// into another record is that record's, and a create on it would mint
|
|
596
|
+
// the duplicate the merge removed.
|
|
597
|
+
const existing = await findContactByEmail(collection, email);
|
|
598
|
+
if (existing) {
|
|
599
|
+
// Released: the conflict clears if that contact is deleted or merged
|
|
600
|
+
// upstream, and the retry that should then succeed must not replay it.
|
|
601
|
+
await claim.release();
|
|
602
|
+
return ApiErrors.conflict({
|
|
603
|
+
message: `A contact with this email already exists (${existing.id}). Update it instead.`,
|
|
604
|
+
code: 'contact_exists',
|
|
605
|
+
headers: ctx.headers
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
// Three aggregate reads, the same ones `upsertHostContact` and the
|
|
609
|
+
// monthly rollup take — the band counts companies and deals beside the
|
|
610
|
+
// contacts (AGL-2611). Unconditional, matching `createDataset` — the
|
|
611
|
+
// alternative is a per-plan shape check like `dataStorageEnforcementShape`,
|
|
612
|
+
// and a `count()` costs one read against a write that costs one anyway.
|
|
613
|
+
const quota = await crmRecordsQuotaForOrg(ctx.org, ctx.firestore.collection('orgs').doc(ctx.orgId), collection);
|
|
614
|
+
if (!quota.allowed) {
|
|
615
|
+
await claim.release();
|
|
616
|
+
// `contact_quota` is the code this endpoint has always answered with
|
|
617
|
+
// and integrators match on it; the band behind it widened, the code
|
|
618
|
+
// did not. Companies and deals answer `crm_records_quota`.
|
|
619
|
+
return ApiErrors.planRequired({
|
|
620
|
+
message: `CRM records limit reached (${quota.included} records across ` + 'contacts, companies and deals). Upgrade the plan to add more.',
|
|
621
|
+
code: 'contact_quota',
|
|
622
|
+
headers: ctx.headers
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const id = createResourceUid();
|
|
626
|
+
await collection.doc(id).create(_extends({
|
|
627
|
+
email
|
|
628
|
+
}, name ? nameSearchFields(name) : {}, {
|
|
629
|
+
tags: tags != null ? tags : []
|
|
630
|
+
}, notes ? {
|
|
631
|
+
notes
|
|
632
|
+
} : {}, Object.keys(customValues).length ? {
|
|
633
|
+
custom: customValues
|
|
634
|
+
} : {}, marketingConsent && consentSiteId ? _extends({}, marketingConsentFieldsForGroup(apiGrantGroup(ctx, consentSiteId, consentGroupId), Date.now())) : {}, {
|
|
635
|
+
[CAPTURED_BY_HOST_FIELD]: consentSiteId ? [
|
|
636
|
+
consentSiteId
|
|
637
|
+
] : []
|
|
638
|
+
}, marketingConsent === false ? {
|
|
639
|
+
marketingConsent: false
|
|
640
|
+
} : {}, consentSiteId ? contactCrmCreateFields(ctx, crm, consentSiteId) : {}, {
|
|
641
|
+
// `sources.api` — a first-class provenance value beside `form`,
|
|
642
|
+
// `member`, `order` and `booking`, so a merchant reading the console
|
|
643
|
+
// can see which people an integration put there.
|
|
644
|
+
sources: {
|
|
645
|
+
api: true
|
|
646
|
+
},
|
|
647
|
+
interactions: [],
|
|
648
|
+
// Nothing is scheduled against a new person, which the console's
|
|
649
|
+
// "No next activity" filter asks as `null` (AGL-3321).
|
|
650
|
+
nextTaskAtMs: null,
|
|
651
|
+
// AGL-1044/AGL-1037: without this the contact matches no scoped read.
|
|
652
|
+
visibleTo: [
|
|
653
|
+
ORG_SCOPE_TOKEN
|
|
654
|
+
],
|
|
655
|
+
createdAt: Timestamp.now(),
|
|
656
|
+
updatedAt: Timestamp.now()
|
|
657
|
+
}));
|
|
658
|
+
// What the console's Contacts list searches and filters by (AGL-3321),
|
|
659
|
+
// from the row as it was created, nested facet and all.
|
|
660
|
+
await restampCrmListFieldsAt(collection.doc(id), 'contacts');
|
|
661
|
+
// The address's domain, checked after the response (AGL-3328): a domain
|
|
662
|
+
// that takes no mail makes the new contact read "Would bounce".
|
|
663
|
+
scheduleCapturedEmailCheck({
|
|
664
|
+
orgId: ctx.orgId,
|
|
665
|
+
email
|
|
666
|
+
});
|
|
667
|
+
// The company the body named has one more contact naming it (AGL-2613);
|
|
668
|
+
// a fresh row's plan is the trivial one, and `crmRefErrors` has already
|
|
669
|
+
// required the company to exist.
|
|
670
|
+
if (consentSiteId && crm.companyId) {
|
|
671
|
+
await settleCompanyContactsCounts(companiesCollection(ctx), planContactCompanyLink({
|
|
672
|
+
companyId: null,
|
|
673
|
+
companyIds: [],
|
|
674
|
+
heldElsewhere: []
|
|
675
|
+
}, crm.companyId));
|
|
676
|
+
}
|
|
677
|
+
const view = contactView(await collection.doc(id).get());
|
|
678
|
+
// Stored as 200 so a replay is distinguishable from the fresh 201.
|
|
679
|
+
await claim.record(200, view);
|
|
680
|
+
return apiJson(view, {
|
|
681
|
+
status: 201,
|
|
682
|
+
headers: ctx.headers
|
|
683
|
+
});
|
|
684
|
+
} catch (error) {
|
|
685
|
+
await claim.release();
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* `PATCH /v1/contacts/{id}` — the tag/notes edit the console's Contacts page
|
|
691
|
+
* already makes, plus the name and the marketing flag.
|
|
692
|
+
*
|
|
693
|
+
* No `Idempotency-Key`, for `updateRecord`'s reason: the same body twice
|
|
694
|
+
* lands the same state AND returns the same `200`. No quota either — an edit
|
|
695
|
+
* does not grow the audience, and charging a plan refusal for renaming
|
|
696
|
+
* somebody would make a downgraded org unable to correct its own data.
|
|
697
|
+
*/ async function updateContact(request, ctx, contactRef) {
|
|
698
|
+
const parsed = readContactInput(await readJsonBody(request), {
|
|
699
|
+
partial: true
|
|
700
|
+
});
|
|
701
|
+
if ('errors' in parsed) {
|
|
702
|
+
return ApiErrors.badRequest({
|
|
703
|
+
message: 'Contact failed validation',
|
|
704
|
+
code: 'validation_failed',
|
|
705
|
+
fields: parsed.errors,
|
|
706
|
+
headers: ctx.headers
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const snap = await contactRef.get();
|
|
710
|
+
if (!snap.exists) {
|
|
711
|
+
return ApiErrors.notFound({
|
|
712
|
+
message: 'No such contact',
|
|
713
|
+
headers: ctx.headers
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
const { name, tags, notes, marketingConsent, consentSiteId, consentGroupId, crm } = parsed.values;
|
|
717
|
+
if (consentSiteId && !orgOwnsHost(ctx, consentSiteId)) {
|
|
718
|
+
return ApiErrors.badRequest({
|
|
719
|
+
message: 'Contact failed validation',
|
|
720
|
+
code: 'validation_failed',
|
|
721
|
+
fields: {
|
|
722
|
+
consentSiteId: 'No such site in this organization'
|
|
723
|
+
},
|
|
724
|
+
headers: ctx.headers
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
const crmErrors = await contactCrmRefErrors(ctx, crm);
|
|
728
|
+
if (Object.keys(crmErrors).length) {
|
|
729
|
+
return ApiErrors.badRequest({
|
|
730
|
+
message: 'Contact failed validation',
|
|
731
|
+
code: 'validation_failed',
|
|
732
|
+
fields: crmErrors,
|
|
733
|
+
headers: ctx.headers
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const update = {};
|
|
737
|
+
// A rename must move the search keys with it, or the contact stays findable
|
|
738
|
+
// only by the name they no longer have.
|
|
739
|
+
if (name !== undefined) Object.assign(update, nameSearchFields(name));
|
|
740
|
+
if (tags !== undefined) update.tags = tags;
|
|
741
|
+
if (notes !== undefined) update.notes = notes;
|
|
742
|
+
if (parsed.values.custom !== undefined) {
|
|
743
|
+
const custom = readContactCustomInput(parsed.values.custom, await readOrgContactFieldDefinitions(ctx));
|
|
744
|
+
if ('errors' in custom) {
|
|
745
|
+
return ApiErrors.badRequest({
|
|
746
|
+
message: 'Contact failed validation',
|
|
747
|
+
code: 'validation_failed',
|
|
748
|
+
fields: custom.errors,
|
|
749
|
+
headers: ctx.headers
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
/*
|
|
753
|
+
* One dotted path per key, because this is an `update` and an `update`
|
|
754
|
+
* REPLACES a map it is handed whole. `custom` on a PATCH means "these
|
|
755
|
+
* keys", the way the console's own save writes only the keys that
|
|
756
|
+
* changed; an integration correcting one value must not have to resend
|
|
757
|
+
* the other nine to keep them (AGL-2601).
|
|
758
|
+
*/ for (const [key, value] of Object.entries(custom.values)){
|
|
759
|
+
update[`custom.${key}`] = value;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (marketingConsent === true && consentSiteId) {
|
|
763
|
+
/*
|
|
764
|
+
* A dotted path rather than a nested object, because this is an `update`
|
|
765
|
+
* and an `update` REPLACES a map field it is handed whole. Writing the
|
|
766
|
+
* nested form here would delete every other site's grant — the exact
|
|
767
|
+
* over-application this change exists to end, arriving through the write
|
|
768
|
+
* side instead of the read side.
|
|
769
|
+
*/ // The named site, or its whole declared consent group when the body
|
|
770
|
+
// echoed the group it showed the person (AGL-3320).
|
|
771
|
+
const group = apiGrantGroup(ctx, consentSiteId, consentGroupId);
|
|
772
|
+
for (const hostId of group.hostIds){
|
|
773
|
+
var _group_name;
|
|
774
|
+
update[`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}`] = _extends({
|
|
775
|
+
marketingConsent: true,
|
|
776
|
+
// The consent timestamp is the evidence, so it is stamped when
|
|
777
|
+
// consent is GIVEN and left alone when it is withdrawn — an audit
|
|
778
|
+
// needs to know when the person opted in, and clearing it would
|
|
779
|
+
// destroy that record.
|
|
780
|
+
marketingConsentAtMs: Date.now()
|
|
781
|
+
}, group.declared ? {
|
|
782
|
+
consentGroupId: group.groupId,
|
|
783
|
+
consentGroupName: (_group_name = group.name) != null ? _group_name : ''
|
|
784
|
+
} : {});
|
|
785
|
+
}
|
|
786
|
+
} else if (marketingConsent === false) {
|
|
787
|
+
update.marketingConsent = false;
|
|
788
|
+
}
|
|
789
|
+
let link = null;
|
|
790
|
+
if (consentSiteId) {
|
|
791
|
+
var _snap_data;
|
|
792
|
+
const crmFields = contactCrmUpdateFields(ctx, (_snap_data = snap.data()) != null ? _snap_data : {}, crm, consentSiteId);
|
|
793
|
+
Object.assign(update, crmFields.update);
|
|
794
|
+
link = crmFields.link;
|
|
795
|
+
}
|
|
796
|
+
// An empty body is a no-op answered with the current contact, matching
|
|
797
|
+
// `updateDataset`: a client re-sending an unchanged object should not have
|
|
798
|
+
// to special-case it.
|
|
799
|
+
if (Object.keys(update).length > 0) {
|
|
800
|
+
await contactRef.update(_extends({}, update, {
|
|
801
|
+
updatedAt: Timestamp.now()
|
|
802
|
+
}));
|
|
803
|
+
// What the console's Contacts list searches and filters by (AGL-3321).
|
|
804
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
805
|
+
// The companies the link moved off or onto, counted after the contact
|
|
806
|
+
// is written (AGL-2613); `crmRefErrors` has already required the new
|
|
807
|
+
// company to exist.
|
|
808
|
+
await settleCompanyContactsCounts(companiesCollection(ctx), link);
|
|
809
|
+
}
|
|
810
|
+
// Read back through the site the write named, so what the client sees is
|
|
811
|
+
// the profile it just wrote and not another holder's.
|
|
812
|
+
const groupId = consentSiteId ? consentGroupForHost(ctx.org, consentSiteId).groupId : null;
|
|
813
|
+
return apiJson(contactView(await contactRef.get(), groupId), {
|
|
814
|
+
headers: ctx.headers
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* `DELETE /v1/contacts/{id}` — the console's own delete, over the API.
|
|
819
|
+
*
|
|
820
|
+
* Deletes the document whoever else holds it, and keeps every refusal it
|
|
821
|
+
* held — each site's and the unscoped one — in the organization's retained
|
|
822
|
+
* store in the same transaction (AGL-3338), so deleting a person over the
|
|
823
|
+
* API is not a way to add somebody who said no to a list.
|
|
824
|
+
*
|
|
825
|
+
* Takes an `Idempotency-Key` with `deleteRecord`'s exact semantics, and for a
|
|
826
|
+
* sharper reason: an erasure request is the operation most likely to be run
|
|
827
|
+
* from a script on somebody else's deadline, and a retry after a lost
|
|
828
|
+
* response must be able to tell "already erased" from "wrong id".
|
|
829
|
+
*/ async function deleteContact(request, ctx, contactRef) {
|
|
830
|
+
const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'contact-deletes');
|
|
831
|
+
if ('replay' in claimed) return claimed.replay;
|
|
832
|
+
const { claim } = claimed;
|
|
833
|
+
try {
|
|
834
|
+
const removed = await removeContactKeepingRefusals({
|
|
835
|
+
contactRef,
|
|
836
|
+
decide: deleteWholeContact,
|
|
837
|
+
nowMs: Date.now()
|
|
838
|
+
});
|
|
839
|
+
if (removed.outcome === 'missing') {
|
|
840
|
+
await claim.release();
|
|
841
|
+
return ApiErrors.notFound({
|
|
842
|
+
message: 'No such contact',
|
|
843
|
+
headers: ctx.headers
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const view = {
|
|
847
|
+
id: contactRef.id,
|
|
848
|
+
object: 'contact',
|
|
849
|
+
deleted: true
|
|
850
|
+
};
|
|
851
|
+
await claim.record(200, view);
|
|
852
|
+
return apiJson(view, {
|
|
853
|
+
headers: ctx.headers
|
|
854
|
+
});
|
|
855
|
+
} catch (error) {
|
|
856
|
+
await claim.release();
|
|
857
|
+
throw error;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* `GET /v1/contacts` filters (AGL-2460) — the lookup a sync starts with.
|
|
862
|
+
*
|
|
863
|
+
* The list was ordered by document id with no filter of any kind, which left
|
|
864
|
+
* the first question every CRM, mailing tool or migration asks — "do I
|
|
865
|
+
* already have this person?" — answerable only by paging the whole audience.
|
|
866
|
+
* That is not a rounding error. Contacts are ORGANIZATION-wide (AGL-237), so
|
|
867
|
+
* the list is the entire audience band; every page is a BILLED request
|
|
868
|
+
* (`recordApiRequest`) against a documented 120/min per-key ceiling. Finding
|
|
869
|
+
* one address in a 50k audience cost ~500 requests and four minutes, and the
|
|
870
|
+
* integration that gave up and called `POST` instead recovered the id by
|
|
871
|
+
* regex over the `409 contact_exists` sentence — parsing a human message for
|
|
872
|
+
* an identifier, which is not a contract this API should have been offering.
|
|
873
|
+
*
|
|
874
|
+
* Both filters REDUCE work rather than adding it: one indexed lookup replaces
|
|
875
|
+
* a full sweep, so this NARROWS the per-request Firestore read amplification
|
|
876
|
+
* AGL-2414 is about rather than widening it. Neither introduces a new metered
|
|
877
|
+
* dimension — a filtered list is the same billed request the unfiltered one
|
|
878
|
+
* always was, and the customer simply needs far fewer of them.
|
|
879
|
+
*
|
|
880
|
+
* ## `email` runs through the writer's own normalizer
|
|
881
|
+
*
|
|
882
|
+
* `normalizeContactEmail` is the SAME function `createContact` stores
|
|
883
|
+
* through. Matching the raw query string instead would make
|
|
884
|
+
* `?email=Avery@Example.com` answer "no such contact" while `POST` with that
|
|
885
|
+
* identical address answers `409 contact_exists` naming its id — two
|
|
886
|
+
* endpoints disagreeing about whether a person exists, which an integrator
|
|
887
|
+
* reasonably reads as our data being corrupt rather than as our having two
|
|
888
|
+
* spellings of one address.
|
|
889
|
+
*
|
|
890
|
+
* A value that cannot normalize is a `400` naming the field, not an empty
|
|
891
|
+
* page. Every stored email is normalized and pattern-valid, so nothing can
|
|
892
|
+
* match a malformed one and an empty page would be perfectly TRUE and
|
|
893
|
+
* perfectly useless: it is indistinguishable from "we don't have them", and
|
|
894
|
+
* it sends the caller hunting a missing person instead of a typo.
|
|
895
|
+
*
|
|
896
|
+
* ## `email` is a lookup, not a clause
|
|
897
|
+
*
|
|
898
|
+
* The address goes through the org's address index (AGL-2633) — the same
|
|
899
|
+
* lookup every capture door makes — so an address a merge folded into a
|
|
900
|
+
* record as an alternate finds that record, which is the answer `POST`
|
|
901
|
+
* gives when it refuses the same address as a duplicate. One address names
|
|
902
|
+
* one document, so the page is that document or nothing and never carries
|
|
903
|
+
* a cursor.
|
|
904
|
+
*
|
|
905
|
+
* ## Why the COMBINATION filters after the read
|
|
906
|
+
*
|
|
907
|
+
* An `array-contains` on `tags` and the `orderBy(FieldPath.documentId())`
|
|
908
|
+
* every list applies is already a two-clause query; a third would need a
|
|
909
|
+
* composite index we would have to ship and wait on. The address selects at
|
|
910
|
+
* most one document, and a company selects a short page — testing those
|
|
911
|
+
* rows' tags in memory costs nothing and needs no index. The page can
|
|
912
|
+
* therefore come back empty with `has_more` false, which is the short-page
|
|
913
|
+
* case `conventions.md` already tells clients to expect and which
|
|
914
|
+
* `?channel=online` on orders already produces.
|
|
915
|
+
*/ async function listContacts(ctx, collection, url) {
|
|
916
|
+
var _url_searchParams_get, _url_searchParams_get1, _url_searchParams_get2;
|
|
917
|
+
const rawEmail = url.searchParams.get('email');
|
|
918
|
+
let email = null;
|
|
919
|
+
if (rawEmail !== null && rawEmail.trim() !== '') {
|
|
920
|
+
email = normalizeContactEmail(rawEmail);
|
|
921
|
+
if (!email) {
|
|
922
|
+
return ApiErrors.badRequest({
|
|
923
|
+
message: 'Contact filter failed validation',
|
|
924
|
+
code: 'validation_failed',
|
|
925
|
+
fields: {
|
|
926
|
+
email: 'Must be a valid email address'
|
|
927
|
+
},
|
|
928
|
+
headers: ctx.headers
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
// Trimmed and capped exactly as `readContactInput` stores a tag, so a
|
|
933
|
+
// filter cannot ask for a string the write path could never have written.
|
|
934
|
+
const rawTag = url.searchParams.get('tag');
|
|
935
|
+
const tag = rawTag === null ? null : rawTag.trim().slice(0, CONTACT_TAG_MAX) || null;
|
|
936
|
+
/*
|
|
937
|
+
* The CRM filters (AGL-2606). `companyId` can be a clause: it queries the
|
|
938
|
+
* top-level `companyIds` array, the twin every profile write keeps for
|
|
939
|
+
* exactly this. `lifecycleStage` and `ownerUid` cannot — they live on a
|
|
940
|
+
* facet, and a facet field is not queryable without the holder in the
|
|
941
|
+
* path, which the org-wide read does not have — so both are applied to the
|
|
942
|
+
* page, against the SAME profile the view publishes: the named site's, or
|
|
943
|
+
* the union. A filtered page can therefore come back short, as `?email=`
|
|
944
|
+
* with `?tag=` already does. A stage that is not one of the list is a
|
|
945
|
+
* `400`, because `?lifecycleStage=customers` matching nothing is the
|
|
946
|
+
* plausible empty page the conventions refuse to serve.
|
|
947
|
+
*/ const rawStage = ((_url_searchParams_get = url.searchParams.get('lifecycleStage')) != null ? _url_searchParams_get : '').trim();
|
|
948
|
+
if (rawStage && !isContactLifecycleStage(rawStage)) {
|
|
949
|
+
return ApiErrors.badRequest({
|
|
950
|
+
message: 'Contact filter failed validation',
|
|
951
|
+
code: 'validation_failed',
|
|
952
|
+
fields: {
|
|
953
|
+
lifecycleStage: `Must be one of: ${CONTACT_LIFECYCLE_STAGES.join(', ')}`
|
|
954
|
+
},
|
|
955
|
+
headers: ctx.headers
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
const lifecycleStage = rawStage || null;
|
|
959
|
+
const ownerUid = ((_url_searchParams_get1 = url.searchParams.get('ownerUid')) != null ? _url_searchParams_get1 : '').trim().slice(0, CONTACT_NAME_MAX) || null;
|
|
960
|
+
const companyId = ((_url_searchParams_get2 = url.searchParams.get('companyId')) != null ? _url_searchParams_get2 : '').trim().slice(0, CRM_ID_MAX) || null;
|
|
961
|
+
const group = contactViewGroup(ctx, url);
|
|
962
|
+
if ('response' in group) return group.response;
|
|
963
|
+
// ONE selector, the most selective that was given: the unique email, then
|
|
964
|
+
// the company, then the tag. Every other filter is checked on the page.
|
|
965
|
+
let docs;
|
|
966
|
+
let nextCursor = null;
|
|
967
|
+
let clause = null;
|
|
968
|
+
if (email) {
|
|
969
|
+
const found = await findContactByEmail(collection, email);
|
|
970
|
+
docs = found ? [
|
|
971
|
+
found
|
|
972
|
+
] : [];
|
|
973
|
+
clause = 'email';
|
|
974
|
+
} else {
|
|
975
|
+
let query = collection;
|
|
976
|
+
if (companyId) {
|
|
977
|
+
query = query.where('companyIds', 'array-contains', companyId);
|
|
978
|
+
clause = 'companyId';
|
|
979
|
+
} else if (tag) {
|
|
980
|
+
query = query.where('tags', 'array-contains', tag);
|
|
981
|
+
clause = 'tag';
|
|
982
|
+
}
|
|
983
|
+
;
|
|
984
|
+
({ docs, nextCursor } = await paginate(query, url));
|
|
985
|
+
}
|
|
986
|
+
const matched = docs.filter((doc)=>{
|
|
987
|
+
var _doc_data;
|
|
988
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
989
|
+
if (tag && clause !== 'tag') {
|
|
990
|
+
if (!Array.isArray(data.tags) || !data.tags.includes(tag)) return false;
|
|
991
|
+
}
|
|
992
|
+
if (companyId && clause !== 'companyId') {
|
|
993
|
+
if (!Array.isArray(data.companyIds) || !data.companyIds.includes(companyId)) {
|
|
994
|
+
return false;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (lifecycleStage || ownerUid) {
|
|
998
|
+
const profile = contactCrmProfile(data, group.groupId);
|
|
999
|
+
if (lifecycleStage && profile.lifecycleStage !== lifecycleStage) return false;
|
|
1000
|
+
if (ownerUid && profile.ownerUid !== ownerUid) return false;
|
|
1001
|
+
}
|
|
1002
|
+
return true;
|
|
1003
|
+
});
|
|
1004
|
+
return listResponse(matched.map((doc)=>contactView(doc, group.groupId)), nextCursor, ctx.headers);
|
|
1005
|
+
}
|
|
1006
|
+
export async function handleContacts(request, ctx, segments, url) {
|
|
1007
|
+
const collection = contactsCollection(ctx);
|
|
1008
|
+
const [, contactId] = segments;
|
|
1009
|
+
if (!contactId) {
|
|
1010
|
+
if (request.method === 'GET') {
|
|
1011
|
+
const denied = requireScope(ctx, 'contacts:read');
|
|
1012
|
+
if (denied) return denied;
|
|
1013
|
+
return listContacts(ctx, collection, url);
|
|
1014
|
+
}
|
|
1015
|
+
if (request.method === 'POST') {
|
|
1016
|
+
const denied = requireScope(ctx, 'contacts:write');
|
|
1017
|
+
if (denied) return denied;
|
|
1018
|
+
return createContact(request, ctx);
|
|
1019
|
+
}
|
|
1020
|
+
return ApiErrors.methodNotAllowed({
|
|
1021
|
+
headers: _extends({}, ctx.headers, {
|
|
1022
|
+
Allow: 'GET, POST'
|
|
1023
|
+
})
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
const contactRef = collection.doc(contactId);
|
|
1027
|
+
// `/v1/contacts/{id}/merge` (AGL-2625): the one action on a contact that
|
|
1028
|
+
// is not a verb on the document itself.
|
|
1029
|
+
if (segments[2] === 'merge' && segments.length === 3) {
|
|
1030
|
+
if (request.method !== 'POST') {
|
|
1031
|
+
return ApiErrors.methodNotAllowed({
|
|
1032
|
+
headers: _extends({}, ctx.headers, {
|
|
1033
|
+
Allow: 'POST'
|
|
1034
|
+
})
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const denied = requireScope(ctx, 'contacts:write');
|
|
1038
|
+
if (denied) return denied;
|
|
1039
|
+
const group = contactViewGroup(ctx, url);
|
|
1040
|
+
if ('response' in group) return group.response;
|
|
1041
|
+
return mergeContactRoute(request, ctx, contactRef, (snap)=>contactView(snap, group.groupId));
|
|
1042
|
+
}
|
|
1043
|
+
if (request.method === 'GET') {
|
|
1044
|
+
const denied = requireScope(ctx, 'contacts:read');
|
|
1045
|
+
if (denied) return denied;
|
|
1046
|
+
const group = contactViewGroup(ctx, url);
|
|
1047
|
+
if ('response' in group) return group.response;
|
|
1048
|
+
const snap = await contactRef.get();
|
|
1049
|
+
if (!snap.exists) {
|
|
1050
|
+
return ApiErrors.notFound({
|
|
1051
|
+
message: 'No such contact',
|
|
1052
|
+
headers: ctx.headers
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
return apiJson(contactView(snap, group.groupId), {
|
|
1056
|
+
headers: ctx.headers
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
if (request.method === 'PATCH') {
|
|
1060
|
+
const denied = requireScope(ctx, 'contacts:write');
|
|
1061
|
+
if (denied) return denied;
|
|
1062
|
+
return updateContact(request, ctx, contactRef);
|
|
1063
|
+
}
|
|
1064
|
+
if (request.method === 'DELETE') {
|
|
1065
|
+
const denied = requireScope(ctx, 'contacts:write');
|
|
1066
|
+
if (denied) return denied;
|
|
1067
|
+
return deleteContact(request, ctx, contactRef);
|
|
1068
|
+
}
|
|
1069
|
+
return ApiErrors.methodNotAllowed({
|
|
1070
|
+
headers: _extends({}, ctx.headers, {
|
|
1071
|
+
Allow: 'GET, PATCH, DELETE'
|
|
1072
|
+
})
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
//# sourceMappingURL=contacts.js.map
|