@aglyn/plugins-crm 1.0.0-beta.218 → 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,631 @@
|
|
|
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/deals` (AGL-2606) — the most commercially sensitive row in the CRM.
|
|
20
|
+
*
|
|
21
|
+
* ## The stage owns the status
|
|
22
|
+
*
|
|
23
|
+
* `status` is denormalized from the stage's `kind` (`crm.ts`), so the two
|
|
24
|
+
* can never disagree on a stored deal. A client may move a deal by either
|
|
25
|
+
* name: `stageId` picks a stage and the status follows; `status` alone picks
|
|
26
|
+
* the pipeline's one `won` or `lost` stage, or its FIRST open stage for
|
|
27
|
+
* `open` — a reopen has to land somewhere, and the top of the pipeline is
|
|
28
|
+
* the only somewhere that needs no second field. Both together must agree,
|
|
29
|
+
* and a pair that does not is refused rather than resolved in favor of
|
|
30
|
+
* either, because whichever one we picked would be the one the client did
|
|
31
|
+
* not mean.
|
|
32
|
+
*
|
|
33
|
+
* Every stage move stamps `stageChangedAtMs`, which is what "stuck in
|
|
34
|
+
* stage" reports read, and a move into or out of a closed stage sets or
|
|
35
|
+
* clears `closedAtMs`.
|
|
36
|
+
*
|
|
37
|
+
* ## A won deal makes its contact a customer
|
|
38
|
+
*
|
|
39
|
+
* The console's stage route floors the linked contact's lifecycle stage at
|
|
40
|
+
* `customer` when a deal is won (AGL-2641), and this resource is the same
|
|
41
|
+
* writer with a different door: a `PATCH` that moves a deal into its `won`
|
|
42
|
+
* stage, or a `POST` that creates one there, applies the same floor to the
|
|
43
|
+
* contact the deal names, in the facet of the deal's site — filling an
|
|
44
|
+
* empty stage, advancing an earlier one, never moving anybody back. The
|
|
45
|
+
* deal's own write is not undone by a contact write that fails; the deal
|
|
46
|
+
* is what the integrator asked for, and the floor is what the win implies.
|
|
47
|
+
*
|
|
48
|
+
* ## The line items own the amount
|
|
49
|
+
*
|
|
50
|
+
* A deal may carry `lineItems` (AGL-2620) — the products behind its value,
|
|
51
|
+
* each in the deal's currency. With one or more lines the stored
|
|
52
|
+
* `amountCents` IS their sum, written beside them, so a typed `amountCents`
|
|
53
|
+
* in the same body, or alone on a deal that has lines, is refused rather
|
|
54
|
+
* than reconciled: whichever number we kept would be the one the client did
|
|
55
|
+
* not mean. `lineItems: []` clears the lines and hands the amount back to
|
|
56
|
+
* the client; the last sum stays until it is retyped. Changing the currency
|
|
57
|
+
* of a deal with lines needs the lines resent in that currency, because
|
|
58
|
+
* their sum is one number in one unit.
|
|
59
|
+
*/ import { CRM_COLLECTIONS, CRM_MEDIA_IDS_MAX, createResourceUid, crmNewRecordListFields, dealStageById, lineItemsTotalCents, normalizeCrmMediaIds, readDealLineItems } from "@aglyn/aglyn/server";
|
|
60
|
+
import { apiJson, ApiErrors, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
|
|
61
|
+
import { Timestamp } from "firebase-admin/firestore";
|
|
62
|
+
import { claimWrite, readJsonBody, requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
63
|
+
import { floorContactLifecycleStage } from "../contact-lifecycle-floor.js";
|
|
64
|
+
import { orderedStages, resolvePipeline } from "./crm-pipelines.js";
|
|
65
|
+
import { CRM_TEXT_MAX, CRM_TITLE_MAX, createPayload, crmCollection, crmCreateStamp, crmCustomUpdate, crmCustomView, crmRecordsBandRefusal, crmRefErrors, crmTimes, crmValidationFailed, isoFromMs, listCrm, memberError, parseIsoInstant, readChoice, readCrmCustomBody, readCrmSite, readEqualityFilters, readOptionalText, readRefId, refuseUnknownKeys, updatePayload } from "./crm-shared.js";
|
|
66
|
+
const DEAL_STATUSES = [
|
|
67
|
+
'open',
|
|
68
|
+
'won',
|
|
69
|
+
'lost'
|
|
70
|
+
];
|
|
71
|
+
/** One line as published: the stored shape, with `productId` always present. */ function lineItemView(item) {
|
|
72
|
+
var _item_productId;
|
|
73
|
+
return {
|
|
74
|
+
productId: (_item_productId = item.productId) != null ? _item_productId : null,
|
|
75
|
+
name: item.name,
|
|
76
|
+
quantity: item.quantity,
|
|
77
|
+
unitAmountCents: item.unitAmountCents,
|
|
78
|
+
currency: item.currency
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** The deal object as published. Every writable field appears here. */ function dealView(doc) {
|
|
82
|
+
var _doc_data, _data_title, _data_pipelineId, _data_stageId, _data_status, _data_currency, _data_ownerUid, _data_contactId, _data_companyId, _data_lostReason, _data_notes, _data_hostId;
|
|
83
|
+
const data = (_doc_data = doc.data()) != null ? _doc_data : {};
|
|
84
|
+
return _extends({
|
|
85
|
+
id: doc.id,
|
|
86
|
+
object: 'deal',
|
|
87
|
+
title: (_data_title = data.title) != null ? _data_title : null,
|
|
88
|
+
pipelineId: (_data_pipelineId = data.pipelineId) != null ? _data_pipelineId : null,
|
|
89
|
+
stageId: (_data_stageId = data.stageId) != null ? _data_stageId : null,
|
|
90
|
+
status: (_data_status = data.status) != null ? _data_status : null,
|
|
91
|
+
amountCents: typeof data.amountCents === 'number' ? data.amountCents : null,
|
|
92
|
+
// `'usd'` when absent, as the model says — a deal with no currency is a
|
|
93
|
+
// dollar deal, not a deal with an unknown one.
|
|
94
|
+
currency: (_data_currency = data.currency) != null ? _data_currency : 'usd',
|
|
95
|
+
lineItems: Array.isArray(data.lineItems) ? data.lineItems.map(lineItemView) : [],
|
|
96
|
+
expectedCloseAt: isoFromMs(data.expectedCloseAtMs),
|
|
97
|
+
closedAt: isoFromMs(data.closedAtMs),
|
|
98
|
+
stageChangedAt: isoFromMs(data.stageChangedAtMs),
|
|
99
|
+
ownerUid: (_data_ownerUid = data.ownerUid) != null ? _data_ownerUid : null,
|
|
100
|
+
contactId: (_data_contactId = data.contactId) != null ? _data_contactId : null,
|
|
101
|
+
companyId: (_data_companyId = data.companyId) != null ? _data_companyId : null,
|
|
102
|
+
lostReason: (_data_lostReason = data.lostReason) != null ? _data_lostReason : null,
|
|
103
|
+
notes: (_data_notes = data.notes) != null ? _data_notes : null,
|
|
104
|
+
// The org's deal custom fields, keyed by field key (AGL-2661).
|
|
105
|
+
custom: crmCustomView(data),
|
|
106
|
+
// When the earliest open task against the deal is due — see
|
|
107
|
+
// `CrmDeal.nextTaskAtMs`. Read-only: the tasks resource maintains it.
|
|
108
|
+
nextTaskAt: isoFromMs(data.nextTaskAtMs),
|
|
109
|
+
mediaIds: normalizeCrmMediaIds(data.mediaIds),
|
|
110
|
+
siteId: (_data_hostId = data.hostId) != null ? _data_hostId : null
|
|
111
|
+
}, crmTimes(data));
|
|
112
|
+
}
|
|
113
|
+
const DEAL_WRITABLE = new Set([
|
|
114
|
+
'title',
|
|
115
|
+
'stageId',
|
|
116
|
+
'status',
|
|
117
|
+
'amountCents',
|
|
118
|
+
'currency',
|
|
119
|
+
'lineItems',
|
|
120
|
+
'expectedCloseAt',
|
|
121
|
+
'ownerUid',
|
|
122
|
+
'contactId',
|
|
123
|
+
'companyId',
|
|
124
|
+
'lostReason',
|
|
125
|
+
'notes',
|
|
126
|
+
'custom',
|
|
127
|
+
'mediaIds'
|
|
128
|
+
]);
|
|
129
|
+
/**
|
|
130
|
+
* The writable half of a deal, validated. `pipelineId` is accepted on a
|
|
131
|
+
* create only: a deal's stages are its pipeline's, and moving one between
|
|
132
|
+
* pipelines is a new deal in the other pipeline, not an edit.
|
|
133
|
+
*/ function readDealInput(body, { partial }) {
|
|
134
|
+
const errors = {};
|
|
135
|
+
const values = {};
|
|
136
|
+
const allowed = new Set(DEAL_WRITABLE);
|
|
137
|
+
if (!partial) {
|
|
138
|
+
allowed.add('consentSiteId');
|
|
139
|
+
allowed.add('pipelineId');
|
|
140
|
+
}
|
|
141
|
+
refuseUnknownKeys(body, allowed, 'deal', errors);
|
|
142
|
+
if (partial && body.pipelineId !== undefined) {
|
|
143
|
+
errors.pipelineId = 'Not writable — create the deal in the other pipeline';
|
|
144
|
+
}
|
|
145
|
+
if (body.mediaIds !== undefined) {
|
|
146
|
+
if (!Array.isArray(body.mediaIds)) {
|
|
147
|
+
errors.mediaIds = 'Must be an array of media ids';
|
|
148
|
+
} else if (body.mediaIds.length > CRM_MEDIA_IDS_MAX) {
|
|
149
|
+
errors.mediaIds = `At most ${CRM_MEDIA_IDS_MAX} files may be attached`;
|
|
150
|
+
} else {
|
|
151
|
+
// The SAME normalizer the console card writes through, so the API
|
|
152
|
+
// cannot store a list the console would refuse. An empty array is
|
|
153
|
+
// legal and clears the attachments.
|
|
154
|
+
values.mediaIds = normalizeCrmMediaIds(body.mediaIds);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (body.title !== undefined || !partial) {
|
|
158
|
+
var _body_title;
|
|
159
|
+
const title = String((_body_title = body.title) != null ? _body_title : '').trim().slice(0, CRM_TITLE_MAX);
|
|
160
|
+
if (title) values.title = title;
|
|
161
|
+
else errors.title = partial ? 'Must not be empty' : 'A title is required';
|
|
162
|
+
}
|
|
163
|
+
if (!partial && body.pipelineId !== undefined) {
|
|
164
|
+
const pipelineId = readRefId(body, 'pipelineId', errors);
|
|
165
|
+
if (pipelineId) values.pipelineId = pipelineId;
|
|
166
|
+
else if (pipelineId === null) errors.pipelineId = 'Must be an id';
|
|
167
|
+
}
|
|
168
|
+
const stageId = readRefId(body, 'stageId', errors);
|
|
169
|
+
if (stageId) values.stageId = stageId;
|
|
170
|
+
else if (stageId === null) errors.stageId = 'Must be a stage id';
|
|
171
|
+
const status = readChoice(body, 'status', DEAL_STATUSES, errors);
|
|
172
|
+
if (status) values.status = status;
|
|
173
|
+
if (body.amountCents !== undefined) {
|
|
174
|
+
if (body.amountCents === null) {
|
|
175
|
+
values.amountCents = null;
|
|
176
|
+
} else if (typeof body.amountCents !== 'number' || !Number.isInteger(body.amountCents) || body.amountCents < 0) {
|
|
177
|
+
errors.amountCents = 'Must be a whole number of cents, 0 or more';
|
|
178
|
+
} else {
|
|
179
|
+
values.amountCents = body.amountCents;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (body.currency !== undefined) {
|
|
183
|
+
var _body_currency;
|
|
184
|
+
const currency = String((_body_currency = body.currency) != null ? _body_currency : '').trim().toLowerCase();
|
|
185
|
+
if (/^[a-z]{3}$/.test(currency)) values.currency = currency;
|
|
186
|
+
else errors.currency = 'Must be a three-letter ISO 4217 code, like usd';
|
|
187
|
+
}
|
|
188
|
+
if (body.lineItems !== undefined) {
|
|
189
|
+
if (body.lineItems === null || Array.isArray(body.lineItems) && body.lineItems.length === 0) {
|
|
190
|
+
values.lineItems = [];
|
|
191
|
+
} else if (!Array.isArray(body.lineItems)) {
|
|
192
|
+
errors.lineItems = 'Must be a list of line items';
|
|
193
|
+
} else {
|
|
194
|
+
values.lineItems = body.lineItems;
|
|
195
|
+
if (body.amountCents !== undefined) {
|
|
196
|
+
errors.amountCents = 'Derived from lineItems — omit it, or send lineItems: [] to type an amount';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (body.expectedCloseAt !== undefined) {
|
|
201
|
+
if (body.expectedCloseAt === null) {
|
|
202
|
+
values.expectedCloseAtMs = null;
|
|
203
|
+
} else {
|
|
204
|
+
const ms = parseIsoInstant(body.expectedCloseAt);
|
|
205
|
+
if (ms === null) {
|
|
206
|
+
errors.expectedCloseAt = 'Must be an ISO 8601 instant, like 2026-12-31T00:00:00Z';
|
|
207
|
+
} else {
|
|
208
|
+
values.expectedCloseAtMs = ms;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const ownerUid = readOptionalText(body, 'ownerUid', CRM_TITLE_MAX, errors);
|
|
213
|
+
if (ownerUid !== undefined) values.ownerUid = ownerUid;
|
|
214
|
+
const contactId = readRefId(body, 'contactId', errors);
|
|
215
|
+
if (contactId !== undefined) values.contactId = contactId;
|
|
216
|
+
const companyId = readRefId(body, 'companyId', errors);
|
|
217
|
+
if (companyId !== undefined) values.companyId = companyId;
|
|
218
|
+
const lostReason = readOptionalText(body, 'lostReason', CRM_TEXT_MAX, errors);
|
|
219
|
+
if (lostReason !== undefined) values.lostReason = lostReason;
|
|
220
|
+
const notes = readOptionalText(body, 'notes', CRM_TEXT_MAX, errors);
|
|
221
|
+
if (notes !== undefined) values.notes = notes;
|
|
222
|
+
// `custom` is shape-checked here and judged against the org's
|
|
223
|
+
// definitions by the writers, which is where the read is paid.
|
|
224
|
+
if (body.custom !== undefined) {
|
|
225
|
+
if (!body.custom || typeof body.custom !== 'object' || Array.isArray(body.custom)) {
|
|
226
|
+
errors.custom = 'Must be an object of field values keyed by field key';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return Object.keys(errors).length ? {
|
|
230
|
+
errors
|
|
231
|
+
} : {
|
|
232
|
+
values
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The stage a write lands the deal in, from `stageId` and/or `status` — see
|
|
237
|
+
* the module header for the rule. `null` when neither was sent, which on a
|
|
238
|
+
* PATCH means "leave the stage alone" and on a create means the first open
|
|
239
|
+
* stage.
|
|
240
|
+
*/ function resolveStage(pipeline, values) {
|
|
241
|
+
const stages = orderedStages(pipeline);
|
|
242
|
+
if (values.stageId) {
|
|
243
|
+
const stage = dealStageById(pipeline, values.stageId);
|
|
244
|
+
if (!stage) return {
|
|
245
|
+
errors: {
|
|
246
|
+
stageId: 'No such stage in this pipeline'
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
if (values.status && values.status !== stage.kind) {
|
|
250
|
+
return {
|
|
251
|
+
errors: {
|
|
252
|
+
status: `Must match the stage, which is ${stage.kind}`
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
stage
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (values.status) {
|
|
261
|
+
const stage = values.status === 'open' ? stages.find((candidate)=>candidate.kind === 'open') : stages.find((candidate)=>candidate.kind === values.status);
|
|
262
|
+
if (!stage) {
|
|
263
|
+
return {
|
|
264
|
+
errors: {
|
|
265
|
+
status: `This pipeline has no ${values.status} stage`
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
stage
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
stage: null
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The stored lines and the amount they derive, for a write that sent
|
|
279
|
+
* `lineItems`, validated against the currency the deal WILL have — the
|
|
280
|
+
* body's, else the stored one, else `usd`. Returns nothing to write when
|
|
281
|
+
* the body carried no lines.
|
|
282
|
+
*/ function placeLineItems(values, stored) {
|
|
283
|
+
var _ref, _values_currency;
|
|
284
|
+
if (values.lineItems === undefined) return {
|
|
285
|
+
fields: {}
|
|
286
|
+
};
|
|
287
|
+
const currency = (_ref = (_values_currency = values.currency) != null ? _values_currency : stored == null ? void 0 : stored.currency) != null ? _ref : 'usd';
|
|
288
|
+
const read = readDealLineItems(values.lineItems, currency);
|
|
289
|
+
if ('error' in read) return {
|
|
290
|
+
errors: {
|
|
291
|
+
lineItems: read.error
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
fields: _extends({
|
|
296
|
+
lineItems: read.items
|
|
297
|
+
}, read.items.length ? {
|
|
298
|
+
amountCents: lineItemsTotalCents(read.items)
|
|
299
|
+
} : {})
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The customer floor a win applies to the deal's contact (AGL-2641), after
|
|
304
|
+
* the deal itself is written. A contact write that fails is logged and
|
|
305
|
+
* swallowed: the deal is already won, and a `500` would tell the integrator
|
|
306
|
+
* the move they can see in the console did not happen.
|
|
307
|
+
*/ async function floorWonDealContact(ctx, deal) {
|
|
308
|
+
var _deal_contactId;
|
|
309
|
+
const contactId = String((_deal_contactId = deal.contactId) != null ? _deal_contactId : '').trim();
|
|
310
|
+
if (!contactId) return;
|
|
311
|
+
try {
|
|
312
|
+
var _deal_hostId;
|
|
313
|
+
await floorContactLifecycleStage({
|
|
314
|
+
contactRef: ctx.firestore.collection('orgs').doc(ctx.orgId).collection('contacts').doc(contactId),
|
|
315
|
+
org: ctx.org,
|
|
316
|
+
hostId: (_deal_hostId = deal.hostId) != null ? _deal_hostId : null,
|
|
317
|
+
floor: 'customer'
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
console.error('[api-v1] deals: the customer floor failed', contactId, error);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** The fields a stage move writes, beside the stage itself. */ function stageMove(stage, nowMs) {
|
|
324
|
+
return {
|
|
325
|
+
stageId: stage.id,
|
|
326
|
+
status: stage.kind,
|
|
327
|
+
stageChangedAtMs: nowMs,
|
|
328
|
+
closedAtMs: stage.kind === 'open' ? null : nowMs
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** The membership and reference checks a deal write makes, merged. */ async function dealRefErrors(ctx, values) {
|
|
332
|
+
var _values_contactId, _values_companyId;
|
|
333
|
+
const [owner, refs] = await Promise.all([
|
|
334
|
+
memberError(ctx, 'ownerUid', values.ownerUid),
|
|
335
|
+
crmRefErrors(ctx, {
|
|
336
|
+
contactId: (_values_contactId = values.contactId) != null ? _values_contactId : undefined,
|
|
337
|
+
companyId: (_values_companyId = values.companyId) != null ? _values_companyId : undefined
|
|
338
|
+
})
|
|
339
|
+
]);
|
|
340
|
+
return _extends({}, owner, refs);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* `POST /v1/deals`. The pipeline is resolved AFTER the claim because
|
|
344
|
+
* resolving it can create one (`resolvePipeline`), and a refusal past that
|
|
345
|
+
* point releases the key so the retry that names a real stage still can.
|
|
346
|
+
*/ async function createDeal(request, ctx) {
|
|
347
|
+
const body = await readJsonBody(request);
|
|
348
|
+
const parsed = readDealInput(body, {
|
|
349
|
+
partial: false
|
|
350
|
+
});
|
|
351
|
+
if ('errors' in parsed) return crmValidationFailed(ctx, 'deal', parsed.errors);
|
|
352
|
+
const site = readCrmSite(ctx, 'deal', body);
|
|
353
|
+
if ('response' in site) return site.response;
|
|
354
|
+
const refErrors = await dealRefErrors(ctx, parsed.values);
|
|
355
|
+
if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'deal', refErrors);
|
|
356
|
+
// Judged against the org's deal definitions, above the claim (AGL-2661).
|
|
357
|
+
const custom = await readCrmCustomBody(ctx, body, 'deal');
|
|
358
|
+
if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'deal', custom.errors);
|
|
359
|
+
const customValues = custom && 'values' in custom ? custom.values : {};
|
|
360
|
+
const collection = crmCollection(ctx, CRM_COLLECTIONS.deals);
|
|
361
|
+
const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'deals');
|
|
362
|
+
if ('replay' in claimed) return claimed.replay;
|
|
363
|
+
const { claim } = claimed;
|
|
364
|
+
try {
|
|
365
|
+
var _placed_stage;
|
|
366
|
+
const _parsed_values = parsed.values, { title, pipelineId, stageId, status, lineItems } = _parsed_values, rest = _object_without_properties_loose(_parsed_values, [
|
|
367
|
+
"title",
|
|
368
|
+
"pipelineId",
|
|
369
|
+
"stageId",
|
|
370
|
+
"status",
|
|
371
|
+
"lineItems"
|
|
372
|
+
]);
|
|
373
|
+
const placedLines = placeLineItems({
|
|
374
|
+
lineItems,
|
|
375
|
+
currency: rest.currency
|
|
376
|
+
}, null);
|
|
377
|
+
if ('errors' in placedLines) {
|
|
378
|
+
await claim.release();
|
|
379
|
+
return crmValidationFailed(ctx, 'deal', placedLines.errors);
|
|
380
|
+
}
|
|
381
|
+
const resolved = await resolvePipeline(ctx, site.siteId, pipelineId);
|
|
382
|
+
if ('error' in resolved) {
|
|
383
|
+
await claim.release();
|
|
384
|
+
return crmValidationFailed(ctx, 'deal', {
|
|
385
|
+
pipelineId: resolved.error
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const placed = resolveStage(resolved.pipeline, {
|
|
389
|
+
stageId,
|
|
390
|
+
status
|
|
391
|
+
});
|
|
392
|
+
if ('errors' in placed) {
|
|
393
|
+
await claim.release();
|
|
394
|
+
return crmValidationFailed(ctx, 'deal', placed.errors);
|
|
395
|
+
}
|
|
396
|
+
const stage = (_placed_stage = placed.stage) != null ? _placed_stage : orderedStages(resolved.pipeline)[0];
|
|
397
|
+
if (!stage) {
|
|
398
|
+
await claim.release();
|
|
399
|
+
return crmValidationFailed(ctx, 'deal', {
|
|
400
|
+
pipelineId: 'This pipeline has no stages'
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// The records band (AGL-2611), asked last so a refusal past the pipeline
|
|
404
|
+
// resolution releases the key exactly as a bad stage does.
|
|
405
|
+
const bandFull = await crmRecordsBandRefusal(ctx);
|
|
406
|
+
if (bandFull) {
|
|
407
|
+
await claim.release();
|
|
408
|
+
return bandFull;
|
|
409
|
+
}
|
|
410
|
+
const id = createResourceUid();
|
|
411
|
+
// One clock: the stage move is stamped with the instant the record's own
|
|
412
|
+
// `createdAt` carries, so a deal created won closed the moment it began.
|
|
413
|
+
const stamp = crmCreateStamp(ctx, site.siteId);
|
|
414
|
+
const record = _extends({
|
|
415
|
+
title,
|
|
416
|
+
titleLower: (title != null ? title : '').toLowerCase(),
|
|
417
|
+
pipelineId: resolved.id
|
|
418
|
+
}, createPayload(_extends({}, rest, placedLines.fields, stageMove(stage, stamp.createdAt.toMillis()))), Object.keys(customValues).length ? {
|
|
419
|
+
custom: createPayload(customValues)
|
|
420
|
+
} : {}, stamp);
|
|
421
|
+
// What the console's Deals list searches and filters by (AGL-3321).
|
|
422
|
+
await collection.doc(id).create(_extends({}, record, crmNewRecordListFields('deals', record)));
|
|
423
|
+
// A deal created won is a won deal: its contact is a customer from birth.
|
|
424
|
+
if (stage.kind === 'won') {
|
|
425
|
+
await floorWonDealContact(ctx, {
|
|
426
|
+
contactId: rest.contactId,
|
|
427
|
+
hostId: site.siteId
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const view = dealView(await collection.doc(id).get());
|
|
431
|
+
await claim.record(200, view);
|
|
432
|
+
return apiJson(view, {
|
|
433
|
+
status: 201,
|
|
434
|
+
headers: ctx.headers
|
|
435
|
+
});
|
|
436
|
+
} catch (error) {
|
|
437
|
+
await claim.release();
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** `PATCH /v1/deals/{id}`. */ async function updateDeal(request, ctx, ref) {
|
|
442
|
+
var _stored_currency;
|
|
443
|
+
const body = await readJsonBody(request);
|
|
444
|
+
const parsed = readDealInput(body, {
|
|
445
|
+
partial: true
|
|
446
|
+
});
|
|
447
|
+
if ('errors' in parsed) return crmValidationFailed(ctx, 'deal', parsed.errors);
|
|
448
|
+
const snap = await ref.get();
|
|
449
|
+
if (!snap.exists) {
|
|
450
|
+
return ApiErrors.notFound({
|
|
451
|
+
message: 'No such deal',
|
|
452
|
+
headers: ctx.headers
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
const refErrors = await dealRefErrors(ctx, parsed.values);
|
|
456
|
+
if (Object.keys(refErrors).length) return crmValidationFailed(ctx, 'deal', refErrors);
|
|
457
|
+
const custom = await readCrmCustomBody(ctx, body, 'deal');
|
|
458
|
+
if (custom && 'errors' in custom) return crmValidationFailed(ctx, 'deal', custom.errors);
|
|
459
|
+
const _parsed_values = parsed.values, { title, stageId, status, lineItems } = _parsed_values, rest = _object_without_properties_loose(_parsed_values, [
|
|
460
|
+
"title",
|
|
461
|
+
"stageId",
|
|
462
|
+
"status",
|
|
463
|
+
"lineItems"
|
|
464
|
+
]);
|
|
465
|
+
const stored = snap.data();
|
|
466
|
+
const storedLines = Array.isArray(stored.lineItems) && stored.lineItems.length > 0;
|
|
467
|
+
const keepsLines = storedLines && lineItems === undefined;
|
|
468
|
+
if (keepsLines && rest.amountCents !== undefined) {
|
|
469
|
+
return crmValidationFailed(ctx, 'deal', {
|
|
470
|
+
amountCents: "This deal's amount is the sum of its line items — edit lineItems instead"
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (keepsLines && rest.currency !== undefined && rest.currency !== ((_stored_currency = stored.currency) != null ? _stored_currency : 'usd')) {
|
|
474
|
+
return crmValidationFailed(ctx, 'deal', {
|
|
475
|
+
currency: "Every line item is in the deal's currency — send lineItems in the new currency with it"
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const placedLines = placeLineItems({
|
|
479
|
+
lineItems,
|
|
480
|
+
currency: rest.currency
|
|
481
|
+
}, stored);
|
|
482
|
+
if ('errors' in placedLines) return crmValidationFailed(ctx, 'deal', placedLines.errors);
|
|
483
|
+
const update = _extends({}, title !== undefined ? {
|
|
484
|
+
title,
|
|
485
|
+
titleLower: title.toLowerCase()
|
|
486
|
+
} : {}, updatePayload(_extends({}, rest, placedLines.fields)), custom && 'values' in custom ? crmCustomUpdate(custom.values) : {});
|
|
487
|
+
// The write's one instant: `updatedAt`, and the stage move if there is one,
|
|
488
|
+
// so a deal closed at T reads updated at T.
|
|
489
|
+
const now = Timestamp.now();
|
|
490
|
+
let won = false;
|
|
491
|
+
if (stageId !== undefined || status !== undefined) {
|
|
492
|
+
const current = snap.data();
|
|
493
|
+
const resolved = await resolvePipeline(ctx, current.hostId, current.pipelineId, {
|
|
494
|
+
allowArchived: true
|
|
495
|
+
});
|
|
496
|
+
if ('error' in resolved) {
|
|
497
|
+
return crmValidationFailed(ctx, 'deal', {
|
|
498
|
+
stageId: "This deal's pipeline no longer exists"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
const placed = resolveStage(resolved.pipeline, {
|
|
502
|
+
stageId,
|
|
503
|
+
status
|
|
504
|
+
});
|
|
505
|
+
if ('errors' in placed) return crmValidationFailed(ctx, 'deal', placed.errors);
|
|
506
|
+
if (placed.stage && placed.stage.id !== current.stageId) {
|
|
507
|
+
Object.assign(update, stageMove(placed.stage, now.toMillis()));
|
|
508
|
+
// The transition INTO won, not a won deal edited: a deal already
|
|
509
|
+
// closed won floored its contact when it closed.
|
|
510
|
+
won = placed.stage.kind === 'won' && current.status !== 'won';
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (Object.keys(update).length > 0) {
|
|
514
|
+
await ref.update(_extends({}, update, {
|
|
515
|
+
updatedAt: now
|
|
516
|
+
}));
|
|
517
|
+
// What the console's Deals list searches (AGL-3321).
|
|
518
|
+
if ('title' in update) await restampCrmListFieldsAt(ref, 'deals');
|
|
519
|
+
}
|
|
520
|
+
if (won) {
|
|
521
|
+
await floorWonDealContact(ctx, {
|
|
522
|
+
contactId: rest.contactId !== undefined ? rest.contactId : stored.contactId,
|
|
523
|
+
hostId: stored.hostId
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return apiJson(dealView(await ref.get()), {
|
|
527
|
+
headers: ctx.headers
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/** `DELETE /v1/deals/{id}` — the deal alone; its tasks and activities stay. */ async function deleteDeal(request, ctx, ref) {
|
|
531
|
+
const claimed = await claimWrite(ctx, '*', request.headers.get('Idempotency-Key'), 'deal-deletes');
|
|
532
|
+
if ('replay' in claimed) return claimed.replay;
|
|
533
|
+
const { claim } = claimed;
|
|
534
|
+
try {
|
|
535
|
+
const snap = await ref.get();
|
|
536
|
+
if (!snap.exists) {
|
|
537
|
+
await claim.release();
|
|
538
|
+
return ApiErrors.notFound({
|
|
539
|
+
message: 'No such deal',
|
|
540
|
+
headers: ctx.headers
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
await ref.delete();
|
|
544
|
+
const view = {
|
|
545
|
+
id: ref.id,
|
|
546
|
+
object: 'deal',
|
|
547
|
+
deleted: true
|
|
548
|
+
};
|
|
549
|
+
await claim.record(200, view);
|
|
550
|
+
return apiJson(view, {
|
|
551
|
+
headers: ctx.headers
|
|
552
|
+
});
|
|
553
|
+
} catch (error) {
|
|
554
|
+
await claim.release();
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* `GET /v1/deals` filters, most selective first — the order `listCrm`
|
|
560
|
+
* indexes them in. `status` is validated against the three values a deal
|
|
561
|
+
* can hold, because `?status=closed` matching nothing is the plausible empty
|
|
562
|
+
* page the conventions refuse to serve.
|
|
563
|
+
*/ async function listDeals(ctx, collection, url) {
|
|
564
|
+
const rawStatus = url.searchParams.get('status');
|
|
565
|
+
if (rawStatus !== null && rawStatus.trim() !== '' && !DEAL_STATUSES.includes(rawStatus.trim())) {
|
|
566
|
+
return crmValidationFailed(ctx, 'deal filter', {
|
|
567
|
+
status: `Must be one of: ${DEAL_STATUSES.join(', ')}`
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const filters = readEqualityFilters(url, [
|
|
571
|
+
'contactId',
|
|
572
|
+
'companyId',
|
|
573
|
+
'pipelineId',
|
|
574
|
+
'ownerUid',
|
|
575
|
+
'status'
|
|
576
|
+
]);
|
|
577
|
+
return listCrm(ctx, collection, url, filters, dealView);
|
|
578
|
+
}
|
|
579
|
+
export async function handleDeals(request, ctx, segments, url) {
|
|
580
|
+
const collection = crmCollection(ctx, CRM_COLLECTIONS.deals);
|
|
581
|
+
const [, dealId] = segments;
|
|
582
|
+
if (!dealId) {
|
|
583
|
+
if (request.method === 'GET') {
|
|
584
|
+
const denied = requireScope(ctx, 'crm:read');
|
|
585
|
+
if (denied) return denied;
|
|
586
|
+
return listDeals(ctx, collection, url);
|
|
587
|
+
}
|
|
588
|
+
if (request.method === 'POST') {
|
|
589
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
590
|
+
if (denied) return denied;
|
|
591
|
+
return createDeal(request, ctx);
|
|
592
|
+
}
|
|
593
|
+
return ApiErrors.methodNotAllowed({
|
|
594
|
+
headers: _extends({}, ctx.headers, {
|
|
595
|
+
Allow: 'GET, POST'
|
|
596
|
+
})
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
const ref = collection.doc(dealId);
|
|
600
|
+
if (request.method === 'GET') {
|
|
601
|
+
const denied = requireScope(ctx, 'crm:read');
|
|
602
|
+
if (denied) return denied;
|
|
603
|
+
const snap = await ref.get();
|
|
604
|
+
if (!snap.exists) {
|
|
605
|
+
return ApiErrors.notFound({
|
|
606
|
+
message: 'No such deal',
|
|
607
|
+
headers: ctx.headers
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return apiJson(dealView(snap), {
|
|
611
|
+
headers: ctx.headers
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (request.method === 'PATCH') {
|
|
615
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
616
|
+
if (denied) return denied;
|
|
617
|
+
return updateDeal(request, ctx, ref);
|
|
618
|
+
}
|
|
619
|
+
if (request.method === 'DELETE') {
|
|
620
|
+
const denied = requireScope(ctx, 'crm:write');
|
|
621
|
+
if (denied) return denied;
|
|
622
|
+
return deleteDeal(request, ctx, ref);
|
|
623
|
+
}
|
|
624
|
+
return ApiErrors.methodNotAllowed({
|
|
625
|
+
headers: _extends({}, ctx.headers, {
|
|
626
|
+
Allow: 'GET, PATCH, DELETE'
|
|
627
|
+
})
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
//# sourceMappingURL=crm-deals.js.map
|