@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,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* What the CRM resources of `/v1` have in common (AGL-2606).
|
|
18
|
+
*
|
|
19
|
+
* Companies, pipelines, deals, tasks and activities are five collections
|
|
20
|
+
* with one shape (`CrmScoped`), one scope model (`crmScopeTokens`) and one
|
|
21
|
+
* set of questions every write has to answer — which site is this for, is
|
|
22
|
+
* that uid a member here, does the contact it names exist. Each handler
|
|
23
|
+
* module answers those through this file, so the answer cannot differ by
|
|
24
|
+
* resource.
|
|
25
|
+
*
|
|
26
|
+
* ## Reads are org-wide, writes name a site
|
|
27
|
+
*
|
|
28
|
+
* An API key is an ORGANIZATION credential (`api-keys.ts`), so a list here
|
|
29
|
+
* reads the whole collection: every company the org's sites know, whichever
|
|
30
|
+
* site created it. That is the same answer `GET /v1/contacts` gives, and it
|
|
31
|
+
* is right for the caller the key represents — an integration acting for the
|
|
32
|
+
* account, not a member sitting on one site.
|
|
33
|
+
*
|
|
34
|
+
* A write is different, because a CRM record carries `visibleTo` and the
|
|
35
|
+
* console enforces it: a record stamped for the wrong scope is a record a
|
|
36
|
+
* site's own team cannot open. So every create names the site it belongs to
|
|
37
|
+
* — `consentSiteId`, the parameter the contacts write already uses for the
|
|
38
|
+
* same question — and the record is stamped exactly as that site's console
|
|
39
|
+
* would stamp it ({@link crmCreateStamp}). There is no default site for the
|
|
40
|
+
* reason the contacts write gives: picking the org's only site works until
|
|
41
|
+
* the org has two.
|
|
42
|
+
*/ import { CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, consentGroupForHost, crmScopeTokens, effectiveCrmLeadSourcePicklist, readCrmCustomInput } from "@aglyn/aglyn/server";
|
|
43
|
+
import { ApiErrors, crmRecordsQuotaForOrg, decodeCursor, encodeCursor, listResponse, parseLimit } from "@aglyn/tenant-data-admin";
|
|
44
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
45
|
+
import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
46
|
+
// ── Bounds ──────────────────────────────────────────────────────────────────
|
|
47
|
+
/** A title, a name — one line. */ export const CRM_TITLE_MAX = 200;
|
|
48
|
+
/** Free text — notes, a body, a lost reason. */ export const CRM_TEXT_MAX = 5000;
|
|
49
|
+
/** A short label — an industry, an outcome, a job title. */ export const CRM_LABEL_MAX = 120;
|
|
50
|
+
/** A stored id — Firestore's own ceiling is 1,500 bytes; nothing here is close. */ export const CRM_ID_MAX = 200;
|
|
51
|
+
// ── Collections ─────────────────────────────────────────────────────────────
|
|
52
|
+
/**
|
|
53
|
+
* `orgs/{orgId}/{name}` — every CRM collection sits directly under the org.
|
|
54
|
+
*
|
|
55
|
+
* Nothing in this module dereferences `CRM_COLLECTIONS` at load time, on
|
|
56
|
+
* purpose: the `/v1` suites mock `@aglyn/aglyn/server` as a closed world,
|
|
57
|
+
* and a module-level `CRM_COLLECTIONS.x` would throw at import in every
|
|
58
|
+
* suite that never asked for the CRM, reading as a failure of theirs.
|
|
59
|
+
*/ export function crmCollection(ctx, name) {
|
|
60
|
+
return ctx.firestore.collection('orgs').doc(ctx.orgId).collection(name);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The records band, asked before a company or a deal is created (AGL-2611).
|
|
64
|
+
*
|
|
65
|
+
* `null` when there is room; the refusal otherwise, in the shape
|
|
66
|
+
* `POST /v1/contacts` has always answered at the same band — `plan_required`
|
|
67
|
+
* with a code, because on the one plan that refuses (Free) an upgrade is the
|
|
68
|
+
* whole remedy, and every paid plan meters the excess instead. The measure
|
|
69
|
+
* is the same three aggregates the monthly rollup bills from, so the API
|
|
70
|
+
* and the invoice cannot disagree about whether the band was full.
|
|
71
|
+
*/ export async function crmRecordsBandRefusal(ctx) {
|
|
72
|
+
const room = await crmRecordsQuotaForOrg(ctx.org, ctx.firestore.collection('orgs').doc(ctx.orgId));
|
|
73
|
+
if (room.allowed) return null;
|
|
74
|
+
return ApiErrors.planRequired({
|
|
75
|
+
message: `CRM records limit reached (${room.included} records across contacts, ` + 'companies and deals). Upgrade the plan to add more.',
|
|
76
|
+
code: 'crm_records_quota',
|
|
77
|
+
headers: ctx.headers
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// ── The site a write names ──────────────────────────────────────────────────
|
|
81
|
+
/**
|
|
82
|
+
* The `consentSiteId` a create carries, validated.
|
|
83
|
+
*
|
|
84
|
+
* Required, and required to be one of the organization's own sites, for the
|
|
85
|
+
* reason the module header gives. The message names the field so an
|
|
86
|
+
* integrator sees the same key they would on a contact write.
|
|
87
|
+
*/ export function readCrmSite(ctx, noun, body) {
|
|
88
|
+
var _body_consentSiteId;
|
|
89
|
+
const refuse = (error)=>({
|
|
90
|
+
response: crmValidationFailed(ctx, noun, {
|
|
91
|
+
consentSiteId: error
|
|
92
|
+
})
|
|
93
|
+
});
|
|
94
|
+
if (body.consentSiteId === undefined) {
|
|
95
|
+
return refuse('Required — name the site this record belongs to');
|
|
96
|
+
}
|
|
97
|
+
const siteId = String((_body_consentSiteId = body.consentSiteId) != null ? _body_consentSiteId : '').trim();
|
|
98
|
+
if (!siteId) return refuse('Must name a site');
|
|
99
|
+
if (!orgOwnsHost(ctx, siteId)) return refuse('No such site in this organization');
|
|
100
|
+
return {
|
|
101
|
+
siteId
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The fields every CRM create stamps, for a record created from `siteId`.
|
|
106
|
+
*
|
|
107
|
+
* `visibleTo` is `crmScopeTokens` — the contact create path's own expression,
|
|
108
|
+
* which is what puts an API-created deal in exactly the scope a deal created
|
|
109
|
+
* from that site's console would land in. `hostId` is provenance and is never
|
|
110
|
+
* rewritten. `createdByUid` is the literal `'api'` because a key has no uid;
|
|
111
|
+
* it is the attribution the contact create makes through `sources.api`, in
|
|
112
|
+
* the field the CRM types already carry.
|
|
113
|
+
*
|
|
114
|
+
* Both timestamps are set here, once, so a fresh record's `updated` equals
|
|
115
|
+
* its `created` and an `updatedAfter` sweep that started before the create
|
|
116
|
+
* picks it up.
|
|
117
|
+
*/ export function crmCreateStamp(ctx, siteId) {
|
|
118
|
+
const org = ctx.org;
|
|
119
|
+
const now = Timestamp.now();
|
|
120
|
+
return {
|
|
121
|
+
visibleTo: crmScopeTokens(org, consentGroupForHost(org, siteId)),
|
|
122
|
+
hostId: siteId,
|
|
123
|
+
createdByUid: 'api',
|
|
124
|
+
createdAt: now,
|
|
125
|
+
updatedAt: now
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// ── Cross-references ────────────────────────────────────────────────────────
|
|
129
|
+
/**
|
|
130
|
+
* Whether `uid` is a member of the authenticated organization.
|
|
131
|
+
*
|
|
132
|
+
* One document read at `orgs/{orgId}/members/{uid}` — the roster
|
|
133
|
+
* `listOrgMembers` reads whole answers the same question for the price of
|
|
134
|
+
* the whole roster, and a create should not pay for members it never asked
|
|
135
|
+
* about.
|
|
136
|
+
*/ export async function isOrgMember(ctx, uid) {
|
|
137
|
+
if (!uid) return false;
|
|
138
|
+
const snap = await ctx.firestore.collection('orgs').doc(ctx.orgId).collection('members').doc(uid).get();
|
|
139
|
+
return snap.exists;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The membership check for an owner or assignee, run after the synchronous
|
|
143
|
+
* grammar so a body that is already refused never spends the read.
|
|
144
|
+
*/ export async function memberError(ctx, field, uid) {
|
|
145
|
+
if (!uid) return {};
|
|
146
|
+
return await isOrgMember(ctx, uid) ? {} : {
|
|
147
|
+
[field]: 'Must be a member of this organization'
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* `values` as an `update()` payload: `null` becomes a field delete, an
|
|
152
|
+
* `undefined` is left out, so an omitted key leaves the field alone and an
|
|
153
|
+
* explicit `null` clears it — the PATCH contract every resource here shares.
|
|
154
|
+
*/ export function updatePayload(values) {
|
|
155
|
+
const update = {};
|
|
156
|
+
for (const [key, value] of Object.entries(values)){
|
|
157
|
+
if (value === undefined) continue;
|
|
158
|
+
update[key] = value === null ? FieldValue.delete() : value;
|
|
159
|
+
}
|
|
160
|
+
return update;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* `values` as a `create()` payload: `null` and `undefined` are both left out,
|
|
164
|
+
* because a fresh document has nothing to clear.
|
|
165
|
+
*/ export function createPayload(values) {
|
|
166
|
+
const stored = {};
|
|
167
|
+
for (const [key, value] of Object.entries(values)){
|
|
168
|
+
if (value !== undefined && value !== null) stored[key] = value;
|
|
169
|
+
}
|
|
170
|
+
return stored;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Each reference in `refs` that points at nothing, as `field → reason`.
|
|
174
|
+
*
|
|
175
|
+
* A dangling id is refused rather than stored because the console renders a
|
|
176
|
+
* task by the contact it names, and a task pointing at a contact that does
|
|
177
|
+
* not exist is a task nobody can find from anywhere. One read per reference
|
|
178
|
+
* given, none for a reference omitted.
|
|
179
|
+
*/ export async function crmRefErrors(ctx, refs) {
|
|
180
|
+
const errors = {};
|
|
181
|
+
const given = [
|
|
182
|
+
'contactId',
|
|
183
|
+
'companyId',
|
|
184
|
+
'dealId'
|
|
185
|
+
].filter((field)=>refs[field]);
|
|
186
|
+
if (given.length === 0) return errors;
|
|
187
|
+
const orgRef = ctx.firestore.collection('orgs').doc(ctx.orgId);
|
|
188
|
+
const collections = {
|
|
189
|
+
contactId: [
|
|
190
|
+
'contacts',
|
|
191
|
+
'contact'
|
|
192
|
+
],
|
|
193
|
+
companyId: [
|
|
194
|
+
CRM_COLLECTIONS.companies,
|
|
195
|
+
'company'
|
|
196
|
+
],
|
|
197
|
+
dealId: [
|
|
198
|
+
CRM_COLLECTIONS.deals,
|
|
199
|
+
'deal'
|
|
200
|
+
]
|
|
201
|
+
};
|
|
202
|
+
await Promise.all(given.map(async (field)=>{
|
|
203
|
+
const [collection, noun] = collections[field];
|
|
204
|
+
const snap = await orgRef.collection(collection).doc(refs[field]).get();
|
|
205
|
+
if (!snap.exists) errors[field] = `No such ${noun} in this organization`;
|
|
206
|
+
}));
|
|
207
|
+
return errors;
|
|
208
|
+
}
|
|
209
|
+
// ── Scalar grammar ──────────────────────────────────────────────────────────
|
|
210
|
+
/**
|
|
211
|
+
* An ISO 8601 instant as milliseconds, or `null` when the value is not one.
|
|
212
|
+
*
|
|
213
|
+
* `Date.parse` is permissive — it takes `"2026"` and `"March 3"` — and a
|
|
214
|
+
* client sending a bare date almost always means midnight in THEIR zone,
|
|
215
|
+
* which we cannot know. So only the full form is accepted: a date, a `T`, a
|
|
216
|
+
* time and an offset (`Z` or `±hh:mm`). Anything less answers `null` and the
|
|
217
|
+
* caller names the field.
|
|
218
|
+
*/ export function parseIsoInstant(value) {
|
|
219
|
+
if (typeof value !== 'string') return null;
|
|
220
|
+
const text = value.trim();
|
|
221
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/.test(text)) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const ms = Date.parse(text);
|
|
225
|
+
return Number.isFinite(ms) ? ms : null;
|
|
226
|
+
}
|
|
227
|
+
/** Milliseconds as ISO 8601, or `null` for anything that is not a finite number. */ export function isoFromMs(value) {
|
|
228
|
+
return typeof value === 'number' && Number.isFinite(value) ? new Date(value).toISOString() : null;
|
|
229
|
+
}
|
|
230
|
+
/** The `created`/`updated` pair every CRM view publishes. */ export function crmTimes(data) {
|
|
231
|
+
var _serialize, _serialize1;
|
|
232
|
+
return {
|
|
233
|
+
created: (_serialize = serialize(data.createdAt)) != null ? _serialize : null,
|
|
234
|
+
updated: (_serialize1 = serialize(data.updatedAt)) != null ? _serialize1 : null
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A trimmed, bounded string from a body field, or `null` for an explicit
|
|
239
|
+
* `null`, or `undefined` when the field was not sent.
|
|
240
|
+
*
|
|
241
|
+
* `null` is how a PATCH clears an optional field — the caller turns it into
|
|
242
|
+
* a delete — so it has to survive as a value distinct from "absent". A
|
|
243
|
+
* number or a boolean is not silently stringified: the field is named.
|
|
244
|
+
*/ export function readOptionalText(body, key, max, errors) {
|
|
245
|
+
const value = body[key];
|
|
246
|
+
if (value === undefined) return undefined;
|
|
247
|
+
if (value === null) return null;
|
|
248
|
+
if (typeof value !== 'string') {
|
|
249
|
+
errors[key] = 'Must be a string';
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
const text = value.trim().slice(0, max);
|
|
253
|
+
return text || null;
|
|
254
|
+
}
|
|
255
|
+
/** One of a fixed list, or the field is named with the list. */ export function readChoice(body, key, choices, errors) {
|
|
256
|
+
const value = body[key];
|
|
257
|
+
if (value === undefined) return undefined;
|
|
258
|
+
if (typeof value === 'string' && choices.includes(value)) {
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
errors[key] = `Must be one of: ${choices.join(', ')}`;
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* A reference id from a body field — trimmed and bounded, `null` to clear.
|
|
266
|
+
*
|
|
267
|
+
* Existence is the caller's question ({@link crmRefErrors}); this only rules
|
|
268
|
+
* out a value that could never be an id.
|
|
269
|
+
*/ export function readRefId(body, key, errors) {
|
|
270
|
+
const value = body[key];
|
|
271
|
+
if (value === undefined) return undefined;
|
|
272
|
+
if (value === null) return null;
|
|
273
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
274
|
+
errors[key] = 'Must be an id';
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
if (value.trim().length > CRM_ID_MAX) {
|
|
278
|
+
errors[key] = 'Must be an id';
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
return value.trim();
|
|
282
|
+
}
|
|
283
|
+
/** Every key of `body` that is not in `allowed`, named as not writable. */ export function refuseUnknownKeys(body, allowed, noun, errors) {
|
|
284
|
+
for (const key of Object.keys(body)){
|
|
285
|
+
if (allowed.has(key)) continue;
|
|
286
|
+
errors[key] = key === 'consentSiteId' ? 'Not writable — the site is set when the record is created' : key === 'siteId' || key === 'hostId' || key === 'visibleTo' ? 'Not writable — set by the site the record was created from' : `Not writable on a ${noun}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** The `400 validation_failed` every CRM write and filter answers with. */ export function crmValidationFailed(ctx, noun, fields) {
|
|
290
|
+
return ApiErrors.badRequest({
|
|
291
|
+
message: `${noun[0].toUpperCase()}${noun.slice(1)} failed validation`,
|
|
292
|
+
code: 'validation_failed',
|
|
293
|
+
fields,
|
|
294
|
+
headers: ctx.headers
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The CRM list grammar: at most one Firestore clause, the rest on the page.
|
|
299
|
+
*
|
|
300
|
+
* ## Why one clause
|
|
301
|
+
*
|
|
302
|
+
* Every equality here has a single-field index, and every list in this API
|
|
303
|
+
* ends in `orderBy(__name__)`, so ONE `where` is served without anything
|
|
304
|
+
* shipped. Two equalities, or an equality beside a range, is a composite
|
|
305
|
+
* index per combination — five resources with four filters each is more
|
|
306
|
+
* indexes than the whole product carries today, every one a query that
|
|
307
|
+
* answers `500` in production until it is deployed by hand. So the FIRST
|
|
308
|
+
* filter in `filters` goes to Firestore, and the caller orders them by
|
|
309
|
+
* selectivity: an id before a status, because `?dealId=` selects a handful
|
|
310
|
+
* of rows and `?status=open` selects most of the collection. The rest are
|
|
311
|
+
* applied to the page, which can come back short — the
|
|
312
|
+
* [short page](apps/docs/api/conventions.md) every filtered list already
|
|
313
|
+
* documents.
|
|
314
|
+
*
|
|
315
|
+
* ## `updatedAfter` changes the order
|
|
316
|
+
*
|
|
317
|
+
* The one range filter, and the one filter that cannot be a post-filter
|
|
318
|
+
* without becoming useless: a sync asking "what changed since T" over an
|
|
319
|
+
* id-ordered list still walks the whole collection to find out. So when it is
|
|
320
|
+
* given the list is ordered by `updated` ascending (then id), the range goes
|
|
321
|
+
* to Firestore, and EVERY equality moves to the page — a range on one field
|
|
322
|
+
* beside an equality on another is the composite index above. The cursor
|
|
323
|
+
* carries the timestamp and the id together, at Firestore's own precision,
|
|
324
|
+
* because a millisecond cursor would replay a row updated in the same
|
|
325
|
+
* millisecond on the next page.
|
|
326
|
+
*/ export async function listCrm(ctx, collection, url, filters, view) {
|
|
327
|
+
const rawUpdatedAfter = url.searchParams.get('updatedAfter');
|
|
328
|
+
const updatedAfter = rawUpdatedAfter === null || rawUpdatedAfter.trim() === '' ? null : parseIsoInstant(rawUpdatedAfter);
|
|
329
|
+
if (rawUpdatedAfter !== null && rawUpdatedAfter.trim() !== '' && updatedAfter === null) {
|
|
330
|
+
return crmValidationFailed(ctx, 'list filter', {
|
|
331
|
+
updatedAfter: 'Must be an ISO 8601 instant, like 2026-09-01T00:00:00Z'
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
const onPage = (docs, applied)=>applied.length === 0 ? docs : docs.filter((doc)=>applied.every(({ field, value })=>doc.get(field) === value));
|
|
335
|
+
if (updatedAfter !== null) {
|
|
336
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
337
|
+
let query = collection.where('updatedAt', '>', Timestamp.fromMillis(updatedAfter)).orderBy('updatedAt').orderBy(FieldPath.documentId()).limit(limit + 1);
|
|
338
|
+
const cursor = decodeUpdatedCursor(decodeCursor(url.searchParams.get('cursor')));
|
|
339
|
+
if (cursor) query = query.startAfter(cursor.updatedAt, cursor.id);
|
|
340
|
+
const snap = await query.get();
|
|
341
|
+
const docs = snap.docs.slice(0, limit);
|
|
342
|
+
const last = docs[docs.length - 1];
|
|
343
|
+
const nextCursor = snap.docs.length > limit && last ? encodeCursor(encodeUpdatedCursor(last.get('updatedAt'), last.id)) : null;
|
|
344
|
+
return listResponse(onPage(docs, filters).map(view), nextCursor, ctx.headers);
|
|
345
|
+
}
|
|
346
|
+
const [indexed, ...rest] = filters;
|
|
347
|
+
const query = indexed ? collection.where(indexed.field, '==', indexed.value) : collection;
|
|
348
|
+
const { docs, nextCursor } = await paginate(query, url);
|
|
349
|
+
return listResponse(onPage(docs, rest).map(view), nextCursor, ctx.headers);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* The `updated`-ordered cursor: `seconds.nanoseconds|id`.
|
|
353
|
+
*
|
|
354
|
+
* Firestore's own precision, not milliseconds — see `listCrm`. The id is the
|
|
355
|
+
* tiebreak for two rows updated in the same instant, which a bulk import
|
|
356
|
+
* produces routinely.
|
|
357
|
+
*/ function encodeUpdatedCursor(updatedAt, id) {
|
|
358
|
+
const stamp = updatedAt instanceof Timestamp ? updatedAt : Timestamp.fromMillis(0);
|
|
359
|
+
return `${stamp.seconds}.${stamp.nanoseconds}|${id}`;
|
|
360
|
+
}
|
|
361
|
+
function decodeUpdatedCursor(raw) {
|
|
362
|
+
if (!raw) return null;
|
|
363
|
+
const match = /^(\d+)\.(\d+)\|(.+)$/.exec(raw);
|
|
364
|
+
if (!match) return null;
|
|
365
|
+
return {
|
|
366
|
+
updatedAt: new Timestamp(Number(match[1]), Number(match[2])),
|
|
367
|
+
id: match[3]
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* The equality filters a list URL carries, in the caller's order, with an
|
|
372
|
+
* empty value meaning "absent" as every filter in this API does.
|
|
373
|
+
*/ export function readEqualityFilters(url, fields) {
|
|
374
|
+
const filters = [];
|
|
375
|
+
for (const field of fields){
|
|
376
|
+
const raw = url.searchParams.get(field);
|
|
377
|
+
if (raw === null) continue;
|
|
378
|
+
const value = raw.trim().slice(0, CRM_ID_MAX);
|
|
379
|
+
if (value) filters.push({
|
|
380
|
+
field,
|
|
381
|
+
value
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return filters;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The org's custom field definitions, retired ones included and every
|
|
388
|
+
* object's, for a resource's `custom` validation (AGL-2661).
|
|
389
|
+
*
|
|
390
|
+
* One bounded read of a small collection, paid only when a body carries
|
|
391
|
+
* `custom` — the contacts resource's own reader, shared so the three
|
|
392
|
+
* resources judge a map the same way. Retired definitions come back too
|
|
393
|
+
* because the reader refuses a write under one BY NAME, which is a better
|
|
394
|
+
* answer than "no such field" for a key the integration wrote last month.
|
|
395
|
+
*/ export async function readOrgFieldDefinitions(ctx) {
|
|
396
|
+
const snapshot = await ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.contactFields).orderBy(FieldPath.documentId()).limit(CONTACT_FIELDS_MAX_PER_ORG).get();
|
|
397
|
+
return snapshot.docs.map((doc)=>doc.data());
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The org's lead source list (AGL-3298) — the values a lead's
|
|
401
|
+
* `leadSource` is restricted to on every write through the API.
|
|
402
|
+
*/ export async function readOrgLeadSourcePicklist(ctx) {
|
|
403
|
+
const snapshot = await ctx.firestore.collection('orgs').doc(ctx.orgId).collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get();
|
|
404
|
+
return effectiveCrmLeadSourcePicklist(snapshot.data());
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* A body's `custom` map, judged against the org's definitions for ONE
|
|
408
|
+
* object, or the refusals to answer with (AGL-2661).
|
|
409
|
+
*
|
|
410
|
+
* `undefined` when the body carried no map — nothing to validate and
|
|
411
|
+
* nothing to write. The read happens only then, so a body without
|
|
412
|
+
* `custom` pays for no definitions.
|
|
413
|
+
*/ export async function readCrmCustomBody(ctx, body, object) {
|
|
414
|
+
if (body.custom === undefined) return undefined;
|
|
415
|
+
return readCrmCustomInput(body.custom, await readOrgFieldDefinitions(ctx), object);
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* A stored `custom` map as published: the map itself, or `{}` on a record
|
|
419
|
+
* that has none, so a client can index it without a guard (AGL-2661).
|
|
420
|
+
*/ export function crmCustomView(data) {
|
|
421
|
+
return data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom) ? data.custom : {};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The `custom` values as a PATCH writes them: one dotted path per key, so
|
|
425
|
+
* the stored map is merged rather than replaced — `custom` on a PATCH
|
|
426
|
+
* means "these keys", and an integration correcting one value must not
|
|
427
|
+
* have to resend the other nine to keep them. A `null` stays a `null`:
|
|
428
|
+
* the key remains, explicitly cleared, the shape a `where` can find.
|
|
429
|
+
*/ export function crmCustomUpdate(values) {
|
|
430
|
+
return Object.fromEntries(Object.entries(values).map(([key, value])=>[
|
|
431
|
+
`custom.${key}`,
|
|
432
|
+
value
|
|
433
|
+
]));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
//# sourceMappingURL=crm-shared.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-shared.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What the CRM resources of `/v1` have in common (AGL-2606).\n *\n * Companies, pipelines, deals, tasks and activities are five collections\n * with one shape (`CrmScoped`), one scope model (`crmScopeTokens`) and one\n * set of questions every write has to answer — which site is this for, is\n * that uid a member here, does the contact it names exist. Each handler\n * module answers those through this file, so the answer cannot differ by\n * resource.\n *\n * ## Reads are org-wide, writes name a site\n *\n * An API key is an ORGANIZATION credential (`api-keys.ts`), so a list here\n * reads the whole collection: every company the org's sites know, whichever\n * site created it. That is the same answer `GET /v1/contacts` gives, and it\n * is right for the caller the key represents — an integration acting for the\n * account, not a member sitting on one site.\n *\n * A write is different, because a CRM record carries `visibleTo` and the\n * console enforces it: a record stamped for the wrong scope is a record a\n * site's own team cannot open. So every create names the site it belongs to\n * — `consentSiteId`, the parameter the contacts write already uses for the\n * same question — and the record is stamped exactly as that site's console\n * would stamp it ({@link crmCreateStamp}). There is no default site for the\n * reason the contacts write gives: picking the org's only site works until\n * the org has two.\n */\nimport {\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n type CrmCollection,\n type CrmCustomValue,\n type CrmFieldObject,\n CRM_LEAD_SOURCE_PICKLIST,\n type CrmPicklist,\n consentGroupForHost,\n crmScopeTokens,\n effectiveCrmLeadSourcePicklist,\n readCrmCustomInput,\n} from '@aglyn/aglyn/server'\nimport {\n ApiErrors,\n crmRecordsQuotaForOrg,\n decodeCursor,\n encodeCursor,\n listResponse,\n parseLimit,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport {\n type ApiV1Context,\n orgOwnsHost,\n paginate,\n serialize,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\n\n// ── Bounds ──────────────────────────────────────────────────────────────────\n\n/** A title, a name — one line. */\nexport const CRM_TITLE_MAX = 200\n/** Free text — notes, a body, a lost reason. */\nexport const CRM_TEXT_MAX = 5000\n/** A short label — an industry, an outcome, a job title. */\nexport const CRM_LABEL_MAX = 120\n/** A stored id — Firestore's own ceiling is 1,500 bytes; nothing here is close. */\nexport const CRM_ID_MAX = 200\n\n// ── Collections ─────────────────────────────────────────────────────────────\n\n/**\n * `orgs/{orgId}/{name}` — every CRM collection sits directly under the org.\n *\n * Nothing in this module dereferences `CRM_COLLECTIONS` at load time, on\n * purpose: the `/v1` suites mock `@aglyn/aglyn/server` as a closed world,\n * and a module-level `CRM_COLLECTIONS.x` would throw at import in every\n * suite that never asked for the CRM, reading as a failure of theirs.\n */\nexport function crmCollection(\n ctx: ApiV1Context,\n name: CrmCollection,\n): FirebaseFirestore.CollectionReference {\n return ctx.firestore.collection('orgs').doc(ctx.orgId).collection(name)\n}\n\n/**\n * The records band, asked before a company or a deal is created (AGL-2611).\n *\n * `null` when there is room; the refusal otherwise, in the shape\n * `POST /v1/contacts` has always answered at the same band — `plan_required`\n * with a code, because on the one plan that refuses (Free) an upgrade is the\n * whole remedy, and every paid plan meters the excess instead. The measure\n * is the same three aggregates the monthly rollup bills from, so the API\n * and the invoice cannot disagree about whether the band was full.\n */\nexport async function crmRecordsBandRefusal(\n ctx: ApiV1Context,\n): Promise<Response | null> {\n const room = await crmRecordsQuotaForOrg(\n ctx.org as never,\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n )\n if (room.allowed) return null\n return ApiErrors.planRequired({\n message:\n `CRM records limit reached (${room.included} records across contacts, ` +\n 'companies and deals). Upgrade the plan to add more.',\n code: 'crm_records_quota',\n headers: ctx.headers,\n })\n}\n\n// ── The site a write names ──────────────────────────────────────────────────\n\n/**\n * The `consentSiteId` a create carries, validated.\n *\n * Required, and required to be one of the organization's own sites, for the\n * reason the module header gives. The message names the field so an\n * integrator sees the same key they would on a contact write.\n */\nexport function readCrmSite(\n ctx: ApiV1Context,\n noun: string,\n body: Record<string, unknown>,\n): { siteId: string } | { response: Response } {\n const refuse = (error: string) => ({\n response: crmValidationFailed(ctx, noun, { consentSiteId: error }),\n })\n if (body.consentSiteId === undefined) {\n return refuse('Required — name the site this record belongs to')\n }\n const siteId = String(body.consentSiteId ?? '').trim()\n if (!siteId) return refuse('Must name a site')\n if (!orgOwnsHost(ctx, siteId)) return refuse('No such site in this organization')\n return { siteId }\n}\n\n/**\n * The fields every CRM create stamps, for a record created from `siteId`.\n *\n * `visibleTo` is `crmScopeTokens` — the contact create path's own expression,\n * which is what puts an API-created deal in exactly the scope a deal created\n * from that site's console would land in. `hostId` is provenance and is never\n * rewritten. `createdByUid` is the literal `'api'` because a key has no uid;\n * it is the attribution the contact create makes through `sources.api`, in\n * the field the CRM types already carry.\n *\n * Both timestamps are set here, once, so a fresh record's `updated` equals\n * its `created` and an `updatedAfter` sweep that started before the create\n * picks it up.\n */\nexport function crmCreateStamp(ctx: ApiV1Context, siteId: string) {\n const org = ctx.org as Record<string, unknown>\n const now = Timestamp.now()\n return {\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, siteId)),\n hostId: siteId,\n createdByUid: 'api',\n createdAt: now,\n updatedAt: now,\n }\n}\n\n// ── Cross-references ────────────────────────────────────────────────────────\n\n/**\n * Whether `uid` is a member of the authenticated organization.\n *\n * One document read at `orgs/{orgId}/members/{uid}` — the roster\n * `listOrgMembers` reads whole answers the same question for the price of\n * the whole roster, and a create should not pay for members it never asked\n * about.\n */\nexport async function isOrgMember(\n ctx: ApiV1Context,\n uid: string,\n): Promise<boolean> {\n if (!uid) return false\n const snap = await ctx.firestore\n .collection('orgs')\n .doc(ctx.orgId)\n .collection('members')\n .doc(uid)\n .get()\n return snap.exists\n}\n\n/** A clearable scalar: a value to write, `null` to delete, absent to leave. */\nexport type Clearable<T> = T | null | undefined\n\n/**\n * The membership check for an owner or assignee, run after the synchronous\n * grammar so a body that is already refused never spends the read.\n */\nexport async function memberError(\n ctx: ApiV1Context,\n field: string,\n uid: Clearable<string>,\n): Promise<Record<string, string>> {\n if (!uid) return {}\n return (await isOrgMember(ctx, uid))\n ? {}\n : { [field]: 'Must be a member of this organization' }\n}\n\n/**\n * `values` as an `update()` payload: `null` becomes a field delete, an\n * `undefined` is left out, so an omitted key leaves the field alone and an\n * explicit `null` clears it — the PATCH contract every resource here shares.\n */\nexport function updatePayload(\n values: Record<string, unknown>,\n): Record<string, unknown> {\n const update: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n if (value === undefined) continue\n update[key] = value === null ? FieldValue.delete() : value\n }\n return update\n}\n\n/**\n * `values` as a `create()` payload: `null` and `undefined` are both left out,\n * because a fresh document has nothing to clear.\n */\nexport function createPayload(\n values: Record<string, unknown>,\n): Record<string, unknown> {\n const stored: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(values)) {\n if (value !== undefined && value !== null) stored[key] = value\n }\n return stored\n}\n\n/** The three records a CRM row can point at, by the field that points. */\nexport interface CrmRefs {\n contactId?: string\n companyId?: string\n dealId?: string\n}\n\n/**\n * Each reference in `refs` that points at nothing, as `field → reason`.\n *\n * A dangling id is refused rather than stored because the console renders a\n * task by the contact it names, and a task pointing at a contact that does\n * not exist is a task nobody can find from anywhere. One read per reference\n * given, none for a reference omitted.\n */\nexport async function crmRefErrors(\n ctx: ApiV1Context,\n refs: CrmRefs,\n): Promise<Record<string, string>> {\n const errors: Record<string, string> = {}\n const given = (['contactId', 'companyId', 'dealId'] as const).filter(\n (field) => refs[field],\n )\n if (given.length === 0) return errors\n const orgRef = ctx.firestore.collection('orgs').doc(ctx.orgId)\n const collections: Record<keyof CrmRefs, [string, string]> = {\n contactId: ['contacts', 'contact'],\n companyId: [CRM_COLLECTIONS.companies, 'company'],\n dealId: [CRM_COLLECTIONS.deals, 'deal'],\n }\n await Promise.all(\n given.map(async (field) => {\n const [collection, noun] = collections[field]\n const snap = await orgRef.collection(collection).doc(refs[field] as string).get()\n if (!snap.exists) errors[field] = `No such ${noun} in this organization`\n }),\n )\n return errors\n}\n\n// ── Scalar grammar ──────────────────────────────────────────────────────────\n\n/**\n * An ISO 8601 instant as milliseconds, or `null` when the value is not one.\n *\n * `Date.parse` is permissive — it takes `\"2026\"` and `\"March 3\"` — and a\n * client sending a bare date almost always means midnight in THEIR zone,\n * which we cannot know. So only the full form is accepted: a date, a `T`, a\n * time and an offset (`Z` or `±hh:mm`). Anything less answers `null` and the\n * caller names the field.\n */\nexport function parseIsoInstant(value: unknown): number | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n if (!/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d{1,9})?)?(?:Z|[+-]\\d{2}:\\d{2})$/.test(text)) {\n return null\n }\n const ms = Date.parse(text)\n return Number.isFinite(ms) ? ms : null\n}\n\n/** Milliseconds as ISO 8601, or `null` for anything that is not a finite number. */\nexport function isoFromMs(value: unknown): string | null {\n return typeof value === 'number' && Number.isFinite(value)\n ? new Date(value).toISOString()\n : null\n}\n\n/** The `created`/`updated` pair every CRM view publishes. */\nexport function crmTimes(data: FirebaseFirestore.DocumentData) {\n return {\n created: (serialize(data.createdAt) as string | undefined) ?? null,\n updated: (serialize(data.updatedAt) as string | undefined) ?? null,\n }\n}\n\n/**\n * A trimmed, bounded string from a body field, or `null` for an explicit\n * `null`, or `undefined` when the field was not sent.\n *\n * `null` is how a PATCH clears an optional field — the caller turns it into\n * a delete — so it has to survive as a value distinct from \"absent\". A\n * number or a boolean is not silently stringified: the field is named.\n */\nexport function readOptionalText(\n body: Record<string, unknown>,\n key: string,\n max: number,\n errors: Record<string, string>,\n): string | null | undefined {\n const value = body[key]\n if (value === undefined) return undefined\n if (value === null) return null\n if (typeof value !== 'string') {\n errors[key] = 'Must be a string'\n return undefined\n }\n const text = value.trim().slice(0, max)\n return text || null\n}\n\n/** One of a fixed list, or the field is named with the list. */\nexport function readChoice<T extends string>(\n body: Record<string, unknown>,\n key: string,\n choices: readonly T[],\n errors: Record<string, string>,\n): T | undefined {\n const value = body[key]\n if (value === undefined) return undefined\n if (typeof value === 'string' && (choices as readonly string[]).includes(value)) {\n return value as T\n }\n errors[key] = `Must be one of: ${choices.join(', ')}`\n return undefined\n}\n\n/**\n * A reference id from a body field — trimmed and bounded, `null` to clear.\n *\n * Existence is the caller's question ({@link crmRefErrors}); this only rules\n * out a value that could never be an id.\n */\nexport function readRefId(\n body: Record<string, unknown>,\n key: string,\n errors: Record<string, string>,\n): string | null | undefined {\n const value = body[key]\n if (value === undefined) return undefined\n if (value === null) return null\n if (typeof value !== 'string' || !value.trim()) {\n errors[key] = 'Must be an id'\n return undefined\n }\n if (value.trim().length > CRM_ID_MAX) {\n errors[key] = 'Must be an id'\n return undefined\n }\n return value.trim()\n}\n\n/** Every key of `body` that is not in `allowed`, named as not writable. */\nexport function refuseUnknownKeys(\n body: Record<string, unknown>,\n allowed: ReadonlySet<string>,\n noun: string,\n errors: Record<string, string>,\n): void {\n for (const key of Object.keys(body)) {\n if (allowed.has(key)) continue\n errors[key] =\n key === 'consentSiteId'\n ? 'Not writable — the site is set when the record is created'\n : key === 'siteId' || key === 'hostId' || key === 'visibleTo'\n ? 'Not writable — set by the site the record was created from'\n : `Not writable on a ${noun}`\n }\n}\n\n/** The `400 validation_failed` every CRM write and filter answers with. */\nexport function crmValidationFailed(\n ctx: ApiV1Context,\n noun: string,\n fields: Record<string, string>,\n): Response {\n return ApiErrors.badRequest({\n message: `${noun[0].toUpperCase()}${noun.slice(1)} failed validation`,\n code: 'validation_failed',\n fields,\n headers: ctx.headers,\n })\n}\n\n// ── Lists ───────────────────────────────────────────────────────────────────\n\n/** One `?field=value` a list accepts, in the order it prefers to index them. */\nexport interface CrmEqualityFilter {\n field: string\n value: string\n}\n\n/**\n * The CRM list grammar: at most one Firestore clause, the rest on the page.\n *\n * ## Why one clause\n *\n * Every equality here has a single-field index, and every list in this API\n * ends in `orderBy(__name__)`, so ONE `where` is served without anything\n * shipped. Two equalities, or an equality beside a range, is a composite\n * index per combination — five resources with four filters each is more\n * indexes than the whole product carries today, every one a query that\n * answers `500` in production until it is deployed by hand. So the FIRST\n * filter in `filters` goes to Firestore, and the caller orders them by\n * selectivity: an id before a status, because `?dealId=` selects a handful\n * of rows and `?status=open` selects most of the collection. The rest are\n * applied to the page, which can come back short — the\n * [short page](apps/docs/api/conventions.md) every filtered list already\n * documents.\n *\n * ## `updatedAfter` changes the order\n *\n * The one range filter, and the one filter that cannot be a post-filter\n * without becoming useless: a sync asking \"what changed since T\" over an\n * id-ordered list still walks the whole collection to find out. So when it is\n * given the list is ordered by `updated` ascending (then id), the range goes\n * to Firestore, and EVERY equality moves to the page — a range on one field\n * beside an equality on another is the composite index above. The cursor\n * carries the timestamp and the id together, at Firestore's own precision,\n * because a millisecond cursor would replay a row updated in the same\n * millisecond on the next page.\n */\nexport async function listCrm(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n filters: CrmEqualityFilter[],\n view: (doc: FirebaseFirestore.QueryDocumentSnapshot) => unknown,\n): Promise<Response> {\n const rawUpdatedAfter = url.searchParams.get('updatedAfter')\n const updatedAfter =\n rawUpdatedAfter === null || rawUpdatedAfter.trim() === ''\n ? null\n : parseIsoInstant(rawUpdatedAfter)\n if (rawUpdatedAfter !== null && rawUpdatedAfter.trim() !== '' && updatedAfter === null) {\n return crmValidationFailed(ctx, 'list filter', {\n updatedAfter: 'Must be an ISO 8601 instant, like 2026-09-01T00:00:00Z',\n })\n }\n\n const onPage = (\n docs: FirebaseFirestore.QueryDocumentSnapshot[],\n applied: CrmEqualityFilter[],\n ) =>\n applied.length === 0\n ? docs\n : docs.filter((doc) =>\n applied.every(({ field, value }) => doc.get(field) === value),\n )\n\n if (updatedAfter !== null) {\n const limit = parseLimit(url.searchParams.get('limit'))\n let query: FirebaseFirestore.Query = collection\n .where('updatedAt', '>', Timestamp.fromMillis(updatedAfter))\n .orderBy('updatedAt')\n .orderBy(FieldPath.documentId())\n .limit(limit + 1)\n const cursor = decodeUpdatedCursor(decodeCursor(url.searchParams.get('cursor')))\n if (cursor) query = query.startAfter(cursor.updatedAt, cursor.id)\n const snap = await query.get()\n const docs = snap.docs.slice(0, limit)\n const last = docs[docs.length - 1]\n const nextCursor =\n snap.docs.length > limit && last\n ? encodeCursor(encodeUpdatedCursor(last.get('updatedAt'), last.id))\n : null\n return listResponse(onPage(docs, filters).map(view), nextCursor, ctx.headers)\n }\n\n const [indexed, ...rest] = filters\n const query: FirebaseFirestore.Query = indexed\n ? collection.where(indexed.field, '==', indexed.value)\n : collection\n const { docs, nextCursor } = await paginate(query, url)\n return listResponse(onPage(docs, rest).map(view), nextCursor, ctx.headers)\n}\n\n/**\n * The `updated`-ordered cursor: `seconds.nanoseconds|id`.\n *\n * Firestore's own precision, not milliseconds — see `listCrm`. The id is the\n * tiebreak for two rows updated in the same instant, which a bulk import\n * produces routinely.\n */\nfunction encodeUpdatedCursor(updatedAt: unknown, id: string): string {\n const stamp = updatedAt instanceof Timestamp ? updatedAt : Timestamp.fromMillis(0)\n return `${stamp.seconds}.${stamp.nanoseconds}|${id}`\n}\n\nfunction decodeUpdatedCursor(\n raw: string | undefined,\n): { updatedAt: Timestamp; id: string } | null {\n if (!raw) return null\n const match = /^(\\d+)\\.(\\d+)\\|(.+)$/.exec(raw)\n if (!match) return null\n return {\n updatedAt: new Timestamp(Number(match[1]), Number(match[2])),\n id: match[3],\n }\n}\n\n/**\n * The equality filters a list URL carries, in the caller's order, with an\n * empty value meaning \"absent\" as every filter in this API does.\n */\nexport function readEqualityFilters(\n url: URL,\n fields: readonly string[],\n): CrmEqualityFilter[] {\n const filters: CrmEqualityFilter[] = []\n for (const field of fields) {\n const raw = url.searchParams.get(field)\n if (raw === null) continue\n const value = raw.trim().slice(0, CRM_ID_MAX)\n if (value) filters.push({ field, value })\n }\n return filters\n}\n\n/**\n * The org's custom field definitions, retired ones included and every\n * object's, for a resource's `custom` validation (AGL-2661).\n *\n * One bounded read of a small collection, paid only when a body carries\n * `custom` — the contacts resource's own reader, shared so the three\n * resources judge a map the same way. Retired definitions come back too\n * because the reader refuses a write under one BY NAME, which is a better\n * answer than \"no such field\" for a key the integration wrote last month.\n */\nexport async function readOrgFieldDefinitions(\n ctx: ApiV1Context,\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await ctx.firestore\n .collection('orgs')\n .doc(ctx.orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .orderBy(FieldPath.documentId())\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n return snapshot.docs.map((doc) => doc.data() as ContactFieldDefinition)\n}\n\n/**\n * The org's lead source list (AGL-3298) — the values a lead's\n * `leadSource` is restricted to on every write through the API.\n */\nexport async function readOrgLeadSourcePicklist(ctx: ApiV1Context): Promise<CrmPicklist> {\n const snapshot = await ctx.firestore\n .collection('orgs')\n .doc(ctx.orgId)\n .collection(CRM_COLLECTIONS.picklists)\n .doc(CRM_LEAD_SOURCE_PICKLIST)\n .get()\n return effectiveCrmLeadSourcePicklist(snapshot.data())\n}\n\n/**\n * A body's `custom` map, judged against the org's definitions for ONE\n * object, or the refusals to answer with (AGL-2661).\n *\n * `undefined` when the body carried no map — nothing to validate and\n * nothing to write. The read happens only then, so a body without\n * `custom` pays for no definitions.\n */\nexport async function readCrmCustomBody(\n ctx: ApiV1Context,\n body: Record<string, unknown>,\n object: CrmFieldObject,\n): Promise<\n | { values: Record<string, CrmCustomValue> }\n | { errors: Record<string, string> }\n | undefined\n> {\n if (body.custom === undefined) return undefined\n return readCrmCustomInput(body.custom, await readOrgFieldDefinitions(ctx), object)\n}\n\n/**\n * A stored `custom` map as published: the map itself, or `{}` on a record\n * that has none, so a client can index it without a guard (AGL-2661).\n */\nexport function crmCustomView(\n data: FirebaseFirestore.DocumentData,\n): Record<string, CrmCustomValue> {\n return data.custom && typeof data.custom === 'object' && !Array.isArray(data.custom)\n ? (data.custom as Record<string, CrmCustomValue>)\n : {}\n}\n\n/**\n * The `custom` values as a PATCH writes them: one dotted path per key, so\n * the stored map is merged rather than replaced — `custom` on a PATCH\n * means \"these keys\", and an integration correcting one value must not\n * have to resend the other nine to keep them. A `null` stays a `null`:\n * the key remains, explicitly cleared, the shape a `where` can find.\n */\nexport function crmCustomUpdate(\n values: Record<string, CrmCustomValue>,\n): Record<string, unknown> {\n return Object.fromEntries(\n Object.entries(values).map(([key, value]) => [`custom.${key}`, value]),\n )\n}\n"],"names":["CONTACT_FIELDS_MAX_PER_ORG","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","consentGroupForHost","crmScopeTokens","effectiveCrmLeadSourcePicklist","readCrmCustomInput","ApiErrors","crmRecordsQuotaForOrg","decodeCursor","encodeCursor","listResponse","parseLimit","FieldPath","FieldValue","Timestamp","orgOwnsHost","paginate","serialize","CRM_TITLE_MAX","CRM_TEXT_MAX","CRM_LABEL_MAX","CRM_ID_MAX","crmCollection","ctx","name","firestore","collection","doc","orgId","crmRecordsBandRefusal","room","org","allowed","planRequired","message","included","code","headers","readCrmSite","noun","body","refuse","error","response","crmValidationFailed","consentSiteId","undefined","siteId","String","trim","crmCreateStamp","now","visibleTo","hostId","createdByUid","createdAt","updatedAt","isOrgMember","uid","snap","get","exists","memberError","field","updatePayload","values","update","key","value","Object","entries","delete","createPayload","stored","crmRefErrors","refs","errors","given","filter","length","orgRef","collections","contactId","companyId","companies","dealId","deals","Promise","all","map","parseIsoInstant","text","test","ms","Date","parse","Number","isFinite","isoFromMs","toISOString","crmTimes","data","created","updated","readOptionalText","max","slice","readChoice","choices","includes","join","readRefId","refuseUnknownKeys","keys","has","fields","badRequest","toUpperCase","listCrm","url","filters","view","rawUpdatedAfter","searchParams","updatedAfter","onPage","docs","applied","every","limit","query","where","fromMillis","orderBy","documentId","cursor","decodeUpdatedCursor","startAfter","id","last","nextCursor","encodeUpdatedCursor","indexed","rest","stamp","seconds","nanoseconds","raw","match","exec","readEqualityFilters","push","readOrgFieldDefinitions","snapshot","contactFields","readOrgLeadSourcePicklist","picklists","readCrmCustomBody","object","custom","crmCustomView","Array","isArray","crmCustomUpdate","fromEntries"],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type ApiV1Context } from '@aglyn/tenant-data-admin/server/api-v1-kit';
|
|
18
|
+
export declare function handleTasks(request: Request, ctx: ApiV1Context, segments: string[], url: URL): Promise<Response>;
|