@aglyn/plugins-crm 1.0.0 → 1.2.0
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 -14
- package/src/lib/components/activity-link-fields.d.ts +34 -0
- package/src/lib/components/activity-link-fields.js +141 -0
- package/src/lib/components/activity-link-fields.js.map +1 -0
- package/src/lib/components/activity-list.d.ts +8 -0
- package/src/lib/components/activity-list.js +4 -2
- package/src/lib/components/activity-list.js.map +1 -1
- package/src/lib/components/activity-queries.d.ts +7 -2
- package/src/lib/components/activity-queries.js +7 -3
- package/src/lib/components/activity-queries.js.map +1 -1
- package/src/lib/components/company-contact-roles-card.d.ts +42 -0
- package/src/lib/components/company-contact-roles-card.js +137 -0
- package/src/lib/components/company-contact-roles-card.js.map +1 -0
- package/src/lib/components/company-detail-page.js +12 -1
- package/src/lib/components/company-detail-page.js.map +1 -1
- package/src/lib/components/company-edit-drawer.js +9 -6
- package/src/lib/components/company-edit-drawer.js.map +1 -1
- package/src/lib/components/company-picker.js +4 -2
- package/src/lib/components/company-picker.js.map +1 -1
- package/src/lib/components/contact-associations-card.js +15 -1
- package/src/lib/components/contact-associations-card.js.map +1 -1
- package/src/lib/components/contact-detail-page.js +13 -14
- package/src/lib/components/contact-detail-page.js.map +1 -1
- package/src/lib/components/contact-known-by-card.d.ts +2 -1
- package/src/lib/components/contact-known-by-card.js +16 -3
- package/src/lib/components/contact-known-by-card.js.map +1 -1
- package/src/lib/components/contact-list-columns.js +5 -4
- package/src/lib/components/contact-list-columns.js.map +1 -1
- package/src/lib/components/contact-properties-card.js +41 -20
- package/src/lib/components/contact-properties-card.js.map +1 -1
- package/src/lib/components/contact-roles-editor.d.ts +54 -0
- package/src/lib/components/contact-roles-editor.js +270 -0
- package/src/lib/components/contact-roles-editor.js.map +1 -0
- package/src/lib/components/contacts-section.js +6 -4
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/crm-console-sections.js +1 -1
- package/src/lib/components/crm-console-sections.js.map +1 -1
- package/src/lib/components/crm-record-picker.d.ts +19 -0
- package/src/lib/components/crm-record-picker.js +40 -11
- package/src/lib/components/crm-record-picker.js.map +1 -1
- package/src/lib/components/crm-related-to-picker.d.ts +33 -0
- package/src/lib/components/crm-related-to-picker.js +135 -0
- package/src/lib/components/crm-related-to-picker.js.map +1 -0
- package/src/lib/components/crm-site-picker.d.ts +13 -1
- package/src/lib/components/crm-site-picker.js +15 -6
- package/src/lib/components/crm-site-picker.js.map +1 -1
- package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
- package/src/lib/components/crm-transfer-buttons.js +9 -6
- package/src/lib/components/crm-transfer-buttons.js.map +1 -1
- package/src/lib/components/deal-contact-roles-card.js +21 -239
- package/src/lib/components/deal-contact-roles-card.js.map +1 -1
- package/src/lib/components/deal-detail-page.js +2 -1
- package/src/lib/components/deal-detail-page.js.map +1 -1
- package/src/lib/components/deal-edit-drawer.js +11 -7
- package/src/lib/components/deal-edit-drawer.js.map +1 -1
- package/src/lib/components/deals-section.js +5 -4
- package/src/lib/components/deals-section.js.map +1 -1
- package/src/lib/components/log-activity-dialog.d.ts +27 -9
- package/src/lib/components/log-activity-dialog.js +96 -32
- package/src/lib/components/log-activity-dialog.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +7 -3
- package/src/lib/components/new-contact-drawer.js +27 -15
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/components/pipeline-stages-dialog.js +5 -1
- package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
- package/src/lib/components/record-activity-card.d.ts +11 -2
- package/src/lib/components/record-activity-card.js +90 -5
- package/src/lib/components/record-activity-card.js.map +1 -1
- package/src/lib/components/settings-section.d.ts +4 -2
- package/src/lib/components/settings-section.js +7 -5
- package/src/lib/components/settings-section.js.map +1 -1
- package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
- package/src/lib/hooks/crm-activity-client-reads.js +50 -0
- package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
- package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
- package/src/lib/hooks/use-campaign-filing-log.js +10 -3
- package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
- package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
- package/src/lib/hooks/use-crm-org-mount.js +2 -1
- package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
- package/src/lib/hooks/use-crm-record-names.d.ts +3 -2
- package/src/lib/hooks/use-crm-record-names.js +4 -4
- package/src/lib/hooks/use-crm-record-names.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +17 -5
- package/src/lib/hooks/use-crm-scope.js +12 -5
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/contact-holder.d.ts +12 -0
- package/src/lib/model/contact-holder.js +20 -1
- package/src/lib/model/contact-holder.js.map +1 -1
- package/src/lib/model/contact-record.d.ts +5 -0
- package/src/lib/model/contact-record.js +7 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/contact-update.d.ts +6 -0
- package/src/lib/model/contact-update.js +1 -0
- package/src/lib/model/contact-update.js.map +1 -1
- package/src/lib/model/crm-activity-link.d.ts +117 -0
- package/src/lib/model/crm-activity-link.js +113 -0
- package/src/lib/model/crm-activity-link.js.map +1 -0
- package/src/lib/model/crm-api.d.ts +4 -1
- package/src/lib/model/crm-api.js +9 -5
- package/src/lib/model/crm-api.js.map +1 -1
- package/src/lib/model/deal-board-model.js +5 -3
- package/src/lib/model/deal-board-model.js.map +1 -1
- package/src/lib/model/deal-contact-roles.d.ts +25 -1
- package/src/lib/model/deal-contact-roles.js +30 -4
- package/src/lib/model/deal-contact-roles.js.map +1 -1
- package/src/lib/model/native-vocabulary.d.ts +9 -0
- package/src/lib/model/native-vocabulary.js +13 -0
- package/src/lib/model/native-vocabulary.js.map +1 -1
- package/src/lib/model/settle-stage-names.d.ts +25 -0
- package/src/lib/model/settle-stage-names.js +29 -0
- package/src/lib/model/settle-stage-names.js.map +1 -0
- package/src/lib/native-nav.js +1 -1
- package/src/lib/native-nav.js.map +1 -1
- package/src/lib/server/add-contact-by-hand.d.ts +84 -0
- package/src/lib/server/add-contact-by-hand.js +212 -0
- package/src/lib/server/add-contact-by-hand.js.map +1 -0
- package/src/lib/server/api-v1/contacts.js +61 -23
- package/src/lib/server/api-v1/contacts.js.map +1 -1
- package/src/lib/server/api-v1/crm-activities.js +13 -3
- package/src/lib/server/api-v1/crm-activities.js.map +1 -1
- package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
- package/src/lib/server/api-v1/crm-pipelines.js +8 -2
- package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
- package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
- package/src/lib/server/api-v1/crm-shared.js +23 -11
- package/src/lib/server/api-v1/crm-shared.js.map +1 -1
- package/src/lib/server/api-v1/openapi.js +3 -3
- package/src/lib/server/api-v1/openapi.js.map +1 -1
- package/src/lib/server/automation-steps.js +11 -9
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/campaign-filing-activity.js +7 -2
- package/src/lib/server/campaign-filing-activity.js.map +1 -1
- package/src/lib/server/contact-update.d.ts +4 -0
- package/src/lib/server/contact-update.js +71 -10
- package/src/lib/server/contact-update.js.map +1 -1
- package/src/lib/server/convert-host-lead.js +13 -2
- package/src/lib/server/convert-host-lead.js.map +1 -1
- package/src/lib/server/convert-open-lead.js +8 -1
- package/src/lib/server/convert-open-lead.js.map +1 -1
- package/src/lib/server/crm-activity-company.d.ts +25 -0
- package/src/lib/server/crm-activity-company.js +34 -0
- package/src/lib/server/crm-activity-company.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +21 -15
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-sharing.js +9 -2
- package/src/lib/server/crm-sharing.js.map +1 -1
- package/src/lib/server/deal-contact-roles.d.ts +2 -0
- package/src/lib/server/deal-contact-roles.js +38 -1
- package/src/lib/server/deal-contact-roles.js.map +1 -1
- package/src/lib/server/email-send.js +10 -8
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +6 -2
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/org-caller.d.ts +8 -0
- package/src/lib/server/org-caller.js +10 -0
- package/src/lib/server/org-caller.js.map +1 -1
- package/src/lib/server/record-timeline.d.ts +3 -1
- package/src/lib/server/record-timeline.js +11 -3
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server.js +152 -43
- package/src/lib/server.js.map +1 -1
- package/src/lib/transfer/contacts.js +83 -48
- package/src/lib/transfer/contacts.js.map +1 -1
- package/src/lib/transfer/fields.js +1 -2
- package/src/lib/transfer/fields.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-activities.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `/v1/activities` (AGL-2606) — one thing that happened, logged by a person\n * or by an integration: a call made, a meeting held, a note taken.\n *\n * An activity is a LOG ENTRY, and a log is written once. There is no\n * `PATCH`: an entry that turns out to be wrong is deleted and logged again,\n * which keeps every row's `at` and `byUid` the record of what was logged\n * rather than what somebody later wished had been. It also has to hang off\n * something — a contact, a company or a deal — because an activity attached\n * to nothing is a sentence nobody can find from anywhere.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmActivityKind,\n CRM_ACTIVITY_DIRECTIONS,\n CRM_ACTIVITY_KINDS,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n type CrmActivityDirection,\n createResourceUid,\n crmActivityDirection,\n crmActivityLogHasRoom,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n countCrmActivitiesForRecord,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n CRM_LABEL_MAX,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readChoice,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n} from './crm-shared'\n\n/** The kinds the API accepts: the CRM's own list, so a kind the console logs is one the API takes. */\nconst ACTIVITY_KINDS = CRM_ACTIVITY_KINDS\n\n/** The activity object as published. */\nfunction activityView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmActivity>\n return {\n id: doc.id,\n object: 'activity',\n kind: data.kind ?? 'note',\n body: data.body ?? null,\n at: isoFromMs(data.atMs),\n byUid: data.byUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n outcome: data.outcome ?? null,\n durationMinutes:\n typeof data.durationMinutes === 'number' ? data.durationMinutes : null,\n direction: crmActivityDirection(data.kind, data.direction),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst ACTIVITY_WRITABLE = new Set([\n 'kind',\n 'body',\n 'at',\n 'byUid',\n 'contactId',\n 'companyId',\n 'dealId',\n 'outcome',\n 'durationMinutes',\n 'direction',\n 'consentSiteId',\n])\n\ninterface ActivityInput {\n kind?: CrmActivityKind\n body: string\n atMs?: number\n byUid?: string\n contactId?: string\n companyId?: string\n dealId?: string\n outcome?: string\n durationMinutes?: number\n direction?: CrmActivityDirection\n}\n\nfunction readActivityInput(\n body: Record<string, unknown>,\n): { values: ActivityInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n refuseUnknownKeys(body, ACTIVITY_WRITABLE, 'activity', errors)\n\n const text = String(body.body ?? '')\n .trim()\n .slice(0, CRM_TEXT_MAX)\n if (!text) errors.body = 'A body is required'\n const values: ActivityInput = { body: text }\n\n const kind = readChoice(body, 'kind', ACTIVITY_KINDS, errors)\n if (kind) values.kind = kind\n\n if (body.at !== undefined) {\n const ms = parseIsoInstant(body.at)\n if (ms === null) errors.at = 'Must be an ISO 8601 instant, like 2026-09-04T16:30:00Z'\n else values.atMs = ms\n }\n\n const byUid = readOptionalText(body, 'byUid', CRM_TITLE_MAX, errors)\n if (byUid) values.byUid = byUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id) values[field] = id\n }\n if (!values.contactId && !values.companyId && !values.dealId && !errors.contactId) {\n errors.contactId = 'Name a contactId, companyId or dealId — an activity is logged against something'\n }\n\n const outcome = readOptionalText(body, 'outcome', CRM_LABEL_MAX, errors)\n if (outcome) values.outcome = outcome\n\n if (body.durationMinutes !== undefined && body.durationMinutes !== null) {\n if (\n typeof body.durationMinutes !== 'number' ||\n !Number.isInteger(body.durationMinutes) ||\n body.durationMinutes < 0\n ) {\n errors.durationMinutes = 'Must be a whole number of minutes, 0 or more'\n } else {\n values.durationMinutes = body.durationMinutes\n }\n }\n\n // Which way a call or an email went (AGL-3517); no other kind takes one.\n if (body.direction !== undefined && body.direction !== null) {\n const kind = values.kind ?? 'note'\n const direction = crmActivityDirection(kind, body.direction)\n const allowed = CRM_ACTIVITY_DIRECTIONS[kind] ?? []\n if (direction) values.direction = direction\n else {\n errors.direction = allowed.length\n ? `Must be one of: ${allowed.join(', ')}`\n : 'Only a call or an email takes a direction'\n }\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n/**\n * `POST /v1/activities`. `at` defaults to now and `byUid` to `'api'`: an\n * integration logging a call as it ends is the common case, and the one\n * logging it on behalf of a member names them.\n */\nasync function createActivity(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const parsed = readActivityInput(body)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'activity', parsed.errors)\n const site = readCrmSite(ctx, 'activity', body)\n if ('response' in site) return site.response\n const [by, refs] = await Promise.all([\n memberError(ctx, 'byUid', parsed.values.byUid),\n crmRefErrors(ctx, {\n contactId: parsed.values.contactId,\n companyId: parsed.values.companyId,\n dealId: parsed.values.dealId,\n }),\n ])\n const refErrors = { ...by, ...refs }\n if (Object.keys(refErrors).length) {\n return crmValidationFailed(ctx, 'activity', refErrors)\n }\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activities',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { kind, atMs, byUid, ...rest } = parsed.values\n // The per-record ceiling (AGL-2611): one aggregate on the record the\n // activity is filed under. A platform bound rather than a plan one, so\n // it is a conflict with the record's state, not a plan refusal.\n const logged = await countCrmActivitiesForRecord(\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n parsed.values,\n )\n if (!crmActivityLogHasRoom(logged)) {\n await claim.release()\n return ApiErrors.conflict({\n message: CRM_ACTIVITY_LOG_FULL_MESSAGE,\n code: 'activity_log_full',\n headers: ctx.headers,\n })\n }\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n await collection.doc(id).create({\n kind: kind ?? 'note',\n // The instant the record was created, from the same clock as its\n // `createdAt`, so an activity logged as it happened reads as one moment.\n atMs: atMs ?? stamp.createdAt.toMillis(),\n byUid: byUid ?? 'api',\n ...createPayload(rest),\n ...stamp,\n })\n const view = activityView(await collection.doc(id).get())\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `DELETE /v1/activities/{id}`. */\nasync function deleteActivity(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activity-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n await ref.delete()\n const view = { id: ref.id, object: 'activity', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/activities` filters, most selective first. */\nasync function listActivities(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawKind = url.searchParams.get('kind')\n if (rawKind !== null && rawKind.trim() !== '' && !(ACTIVITY_KINDS as readonly string[]).includes(rawKind.trim())) {\n return crmValidationFailed(ctx, 'activity filter', {\n kind: `Must be one of: ${ACTIVITY_KINDS.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, ['dealId', 'contactId', 'companyId', 'kind'])\n return listCrm(ctx, collection, url, filters, activityView)\n}\n\nexport async function handleActivities(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const [, activityId] = segments\n\n if (!activityId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listActivities(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createActivity(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(activityId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n return apiJson(activityView(snap), { headers: ctx.headers })\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteActivity(request, ctx, ref)\n }\n // No PATCH — see the module header. The `Allow` header says so rather\n // than a 404 that reads as \"no such activity\".\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_ACTIVITY_DIRECTIONS","CRM_ACTIVITY_KINDS","CRM_ACTIVITY_LOG_FULL_MESSAGE","createResourceUid","crmActivityDirection","crmActivityLogHasRoom","apiJson","ApiErrors","countCrmActivitiesForRecord","claimWrite","readJsonBody","requireScope","CRM_LABEL_MAX","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readChoice","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","ACTIVITY_KINDS","activityView","doc","data","id","object","kind","body","at","atMs","byUid","contactId","companyId","dealId","outcome","durationMinutes","direction","siteId","hostId","ACTIVITY_WRITABLE","Set","readActivityInput","errors","text","String","trim","slice","values","undefined","ms","field","Number","isInteger","allowed","length","join","Object","keys","createActivity","request","ctx","parsed","site","response","by","refs","Promise","all","refErrors","collection","activities","claimed","headers","get","replay","claim","rest","logged","firestore","orgId","release","conflict","message","code","stamp","create","createdAt","toMillis","view","record","status","error","deleteActivity","ref","snap","exists","notFound","delete","deleted","listActivities","url","rawKind","searchParams","includes","filters","handleActivities","segments","activityId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-activities.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `/v1/activities` (AGL-2606) — one thing that happened, logged by a person\n * or by an integration: a call made, a meeting held, a note taken.\n *\n * An activity is a LOG ENTRY, and a log is written once. There is no\n * `PATCH`: an entry that turns out to be wrong is deleted and logged again,\n * which keeps every row's `at` and `byUid` the record of what was logged\n * rather than what somebody later wished had been. It also has to hang off\n * something — a contact, a company or a deal — because an activity attached\n * to nothing is a sentence nobody can find from anywhere.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmActivityKind,\n CRM_ACTIVITY_DIRECTIONS,\n CRM_ACTIVITY_KINDS,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n type CrmActivityDirection,\n consentGroupForHost,\n createResourceUid,\n crmActivityDirection,\n crmActivityLogHasRoom,\n} from '@aglyn/aglyn/server'\nimport {\n apiJson,\n ApiErrors,\n countCrmActivitiesForRecord,\n} from '@aglyn/tenant-data-admin'\nimport {\n type ApiV1Context,\n claimWrite,\n readJsonBody,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport {\n CRM_LABEL_MAX,\n CRM_TEXT_MAX,\n CRM_TITLE_MAX,\n createPayload,\n crmCollection,\n crmCreateStamp,\n crmRefErrors,\n crmTimes,\n crmValidationFailed,\n isoFromMs,\n listCrm,\n memberError,\n parseIsoInstant,\n readChoice,\n readCrmSite,\n readEqualityFilters,\n readOptionalText,\n readRefId,\n refuseUnknownKeys,\n} from './crm-shared'\nimport { crmActivityLinkWithCompany } from '../crm-activity-company'\n\n/** The kinds the API accepts: the CRM's own list, so a kind the console logs is one the API takes. */\nconst ACTIVITY_KINDS = CRM_ACTIVITY_KINDS\n\n/** The activity object as published. */\nfunction activityView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmActivity>\n return {\n id: doc.id,\n object: 'activity',\n kind: data.kind ?? 'note',\n body: data.body ?? null,\n at: isoFromMs(data.atMs),\n byUid: data.byUid ?? null,\n contactId: data.contactId ?? null,\n companyId: data.companyId ?? null,\n dealId: data.dealId ?? null,\n outcome: data.outcome ?? null,\n durationMinutes:\n typeof data.durationMinutes === 'number' ? data.durationMinutes : null,\n direction: crmActivityDirection(data.kind, data.direction),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\nconst ACTIVITY_WRITABLE = new Set([\n 'kind',\n 'body',\n 'at',\n 'byUid',\n 'contactId',\n 'companyId',\n 'dealId',\n 'outcome',\n 'durationMinutes',\n 'direction',\n 'consentSiteId',\n])\n\ninterface ActivityInput {\n kind?: CrmActivityKind\n body: string\n atMs?: number\n byUid?: string\n contactId?: string\n companyId?: string\n dealId?: string\n outcome?: string\n durationMinutes?: number\n direction?: CrmActivityDirection\n}\n\nfunction readActivityInput(\n body: Record<string, unknown>,\n): { values: ActivityInput } | { errors: Record<string, string> } {\n const errors: Record<string, string> = {}\n refuseUnknownKeys(body, ACTIVITY_WRITABLE, 'activity', errors)\n\n const text = String(body.body ?? '')\n .trim()\n .slice(0, CRM_TEXT_MAX)\n if (!text) errors.body = 'A body is required'\n const values: ActivityInput = { body: text }\n\n const kind = readChoice(body, 'kind', ACTIVITY_KINDS, errors)\n if (kind) values.kind = kind\n\n if (body.at !== undefined) {\n const ms = parseIsoInstant(body.at)\n if (ms === null) errors.at = 'Must be an ISO 8601 instant, like 2026-09-04T16:30:00Z'\n else values.atMs = ms\n }\n\n const byUid = readOptionalText(body, 'byUid', CRM_TITLE_MAX, errors)\n if (byUid) values.byUid = byUid\n for (const field of ['contactId', 'companyId', 'dealId'] as const) {\n const id = readRefId(body, field, errors)\n if (id) values[field] = id\n }\n if (!values.contactId && !values.companyId && !values.dealId && !errors.contactId) {\n errors.contactId = 'Name a contactId, companyId or dealId — an activity is logged against something'\n }\n\n const outcome = readOptionalText(body, 'outcome', CRM_LABEL_MAX, errors)\n if (outcome) values.outcome = outcome\n\n if (body.durationMinutes !== undefined && body.durationMinutes !== null) {\n if (\n typeof body.durationMinutes !== 'number' ||\n !Number.isInteger(body.durationMinutes) ||\n body.durationMinutes < 0\n ) {\n errors.durationMinutes = 'Must be a whole number of minutes, 0 or more'\n } else {\n values.durationMinutes = body.durationMinutes\n }\n }\n\n // Which way a call or an email went (AGL-3517); no other kind takes one.\n if (body.direction !== undefined && body.direction !== null) {\n const kind = values.kind ?? 'note'\n const direction = crmActivityDirection(kind, body.direction)\n const allowed = CRM_ACTIVITY_DIRECTIONS[kind] ?? []\n if (direction) values.direction = direction\n else {\n errors.direction = allowed.length\n ? `Must be one of: ${allowed.join(', ')}`\n : 'Only a call or an email takes a direction'\n }\n }\n\n return Object.keys(errors).length ? { errors } : { values }\n}\n\n/**\n * `POST /v1/activities`. `at` defaults to now and `byUid` to `'api'`: an\n * integration logging a call as it ends is the common case, and the one\n * logging it on behalf of a member names them. An activity that names no\n * company rolls up to its deal's, else its contact's (AGL-4123), as every\n * writer's does, and the response says which.\n */\nasync function createActivity(request: Request, ctx: ApiV1Context): Promise<Response> {\n const body = await readJsonBody(request)\n const parsed = readActivityInput(body)\n if ('errors' in parsed) return crmValidationFailed(ctx, 'activity', parsed.errors)\n const site = readCrmSite(ctx, 'activity', body)\n if ('response' in site) return site.response\n const [by, refs] = await Promise.all([\n memberError(ctx, 'byUid', parsed.values.byUid),\n crmRefErrors(ctx, {\n contactId: parsed.values.contactId,\n companyId: parsed.values.companyId,\n dealId: parsed.values.dealId,\n }),\n ])\n const refErrors = { ...by, ...refs }\n if (Object.keys(refErrors).length) {\n return crmValidationFailed(ctx, 'activity', refErrors)\n }\n\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activities',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n\n try {\n const { kind, atMs, byUid, ...rest } = parsed.values\n const org = ctx.org as Record<string, unknown>\n // The per-record ceiling (AGL-2611): one aggregate on the record the\n // activity is filed under. A platform bound rather than a plan one, so\n // it is a conflict with the record's state, not a plan refusal.\n const logged = await countCrmActivitiesForRecord(\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n parsed.values,\n )\n if (!crmActivityLogHasRoom(logged)) {\n await claim.release()\n return ApiErrors.conflict({\n message: CRM_ACTIVITY_LOG_FULL_MESSAGE,\n code: 'activity_log_full',\n headers: ctx.headers,\n })\n }\n const id = createResourceUid()\n const stamp = crmCreateStamp(ctx, site.siteId)\n const { companyId } = await crmActivityLinkWithCompany(\n ctx.firestore.collection('orgs').doc(ctx.orgId),\n parsed.values,\n { groupId: site.siteId ? consentGroupForHost(org, site.siteId).groupId : null, org },\n )\n await collection.doc(id).create({\n kind: kind ?? 'note',\n // The instant the record was created, from the same clock as its\n // `createdAt`, so an activity logged as it happened reads as one moment.\n atMs: atMs ?? stamp.createdAt.toMillis(),\n byUid: byUid ?? 'api',\n ...createPayload({ ...rest, companyId }),\n ...stamp,\n })\n const view = activityView(await collection.doc(id).get())\n await claim.record(200, view)\n return apiJson(view, { status: 201, headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `DELETE /v1/activities/{id}`. */\nasync function deleteActivity(\n request: Request,\n ctx: ApiV1Context,\n ref: FirebaseFirestore.DocumentReference,\n): Promise<Response> {\n const claimed = await claimWrite(\n ctx,\n '*',\n request.headers.get('Idempotency-Key'),\n 'activity-deletes',\n )\n if ('replay' in claimed) return claimed.replay\n const { claim } = claimed\n try {\n const snap = await ref.get()\n if (!snap.exists) {\n await claim.release()\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n await ref.delete()\n const view = { id: ref.id, object: 'activity', deleted: true }\n await claim.record(200, view)\n return apiJson(view, { headers: ctx.headers })\n } catch (error) {\n await claim.release()\n throw error\n }\n}\n\n/** `GET /v1/activities` filters, most selective first. */\nasync function listActivities(\n ctx: ApiV1Context,\n collection: FirebaseFirestore.CollectionReference,\n url: URL,\n): Promise<Response> {\n const rawKind = url.searchParams.get('kind')\n if (rawKind !== null && rawKind.trim() !== '' && !(ACTIVITY_KINDS as readonly string[]).includes(rawKind.trim())) {\n return crmValidationFailed(ctx, 'activity filter', {\n kind: `Must be one of: ${ACTIVITY_KINDS.join(', ')}`,\n })\n }\n const filters = readEqualityFilters(url, ['dealId', 'contactId', 'companyId', 'kind'])\n return listCrm(ctx, collection, url, filters, activityView)\n}\n\nexport async function handleActivities(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.activities)\n const [, activityId] = segments\n\n if (!activityId) {\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n return listActivities(ctx, collection, url)\n }\n if (request.method === 'POST') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return createActivity(request, ctx)\n }\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, POST' },\n })\n }\n\n const ref = collection.doc(activityId)\n if (request.method === 'GET') {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n const snap = await ref.get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such activity', headers: ctx.headers })\n }\n return apiJson(activityView(snap), { headers: ctx.headers })\n }\n if (request.method === 'DELETE') {\n const denied = requireScope(ctx, 'crm:write')\n if (denied) return denied\n return deleteActivity(request, ctx, ref)\n }\n // No PATCH — see the module header. The `Allow` header says so rather\n // than a 404 that reads as \"no such activity\".\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET, DELETE' },\n })\n}\n"],"names":["CRM_COLLECTIONS","CRM_ACTIVITY_DIRECTIONS","CRM_ACTIVITY_KINDS","CRM_ACTIVITY_LOG_FULL_MESSAGE","consentGroupForHost","createResourceUid","crmActivityDirection","crmActivityLogHasRoom","apiJson","ApiErrors","countCrmActivitiesForRecord","claimWrite","readJsonBody","requireScope","CRM_LABEL_MAX","CRM_TEXT_MAX","CRM_TITLE_MAX","createPayload","crmCollection","crmCreateStamp","crmRefErrors","crmTimes","crmValidationFailed","isoFromMs","listCrm","memberError","parseIsoInstant","readChoice","readCrmSite","readEqualityFilters","readOptionalText","readRefId","refuseUnknownKeys","crmActivityLinkWithCompany","ACTIVITY_KINDS","activityView","doc","data","id","object","kind","body","at","atMs","byUid","contactId","companyId","dealId","outcome","durationMinutes","direction","siteId","hostId","ACTIVITY_WRITABLE","Set","readActivityInput","errors","text","String","trim","slice","values","undefined","ms","field","Number","isInteger","allowed","length","join","Object","keys","createActivity","request","ctx","parsed","site","response","by","refs","Promise","all","refErrors","collection","activities","claimed","headers","get","replay","claim","rest","org","logged","firestore","orgId","release","conflict","message","code","stamp","groupId","create","createdAt","toMillis","view","record","status","error","deleteActivity","ref","snap","exists","notFound","delete","deleted","listActivities","url","rawKind","searchParams","includes","filters","handleActivities","segments","activityId","method","denied","methodNotAllowed","Allow"],"mappings":"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"}
|
|
@@ -74,7 +74,7 @@ export interface ResolvedPipeline {
|
|
|
74
74
|
* picked in memory: `isDefault` first, then the lowest id, so two consoles
|
|
75
75
|
* seeding at once still agree. None at all → one is created here.
|
|
76
76
|
*/
|
|
77
|
-
export declare function resolvePipeline(ctx: ApiV1Context, siteId: string, pipelineId: string | undefined, options?: {
|
|
77
|
+
export declare function resolvePipeline(ctx: ApiV1Context, siteId: string | null, pipelineId: string | undefined, options?: {
|
|
78
78
|
allowArchived?: boolean;
|
|
79
79
|
}): Promise<ResolvedPipeline | {
|
|
80
80
|
error: string;
|
|
@@ -33,7 +33,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
33
33
|
* An ARCHIVED pipeline (AGL-2620) is still read — the deals it closed name
|
|
34
34
|
* it — but it takes no new deal: a create that names one is refused, and
|
|
35
35
|
* the default is picked among the active ones.
|
|
36
|
-
*/ import { CRM_COLLECTIONS, createResourceUid, consentGroupForHost,
|
|
36
|
+
*/ import { CRM_COLLECTIONS, createResourceUid, consentGroupForHost, crmReadTokens, DEFAULT_DEAL_STAGES, dealStageForecastCategory, isPipelineArchived, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
|
|
37
37
|
import { apiJson, ApiErrors } from "@aglyn/tenant-data-admin";
|
|
38
38
|
import { FieldPath } from "firebase-admin/firestore";
|
|
39
39
|
import { requireScope } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
@@ -97,7 +97,13 @@ import { crmCollection, crmCreateStamp, crmTimes, isoFromMs, listCrm } from "./c
|
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
99
|
const org = ctx.org;
|
|
100
|
-
|
|
100
|
+
// What the site READS, not what it would stamp: an org-wide pipeline is the
|
|
101
|
+
// default for every site, and looking it up with a site's create tokens
|
|
102
|
+
// missed it and seeded a second "Sales". A record from no site reads the
|
|
103
|
+
// org's own.
|
|
104
|
+
const tokens = siteId ? crmReadTokens(consentGroupForHost(org, siteId)) : [
|
|
105
|
+
ORG_SCOPE_TOKEN
|
|
106
|
+
];
|
|
101
107
|
// Ordered, because a `limit()` with no `orderBy` is a random sample: fifty
|
|
102
108
|
// is more pipelines than any org has, but the bound has to be a bound.
|
|
103
109
|
const visible = await collection.where('visibleTo', 'array-contains-any', tokens).orderBy(FieldPath.documentId()).limit(50).get();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-pipelines.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `/v1/pipelines` (AGL-2606) — the stages a deal moves through, read-only.\n *\n * A pipeline is how one business sells, and its stages are named by the\n * people who sell that way; the API reads them so a deal can be created in\n * the right one and never edits them. What it does do is SEED one: an\n * organization that has never opened the CRM has no pipeline, and a deal\n * cannot exist without a stage, so the first deal created without naming a\n * pipeline gets a default one built from `DEFAULT_DEAL_STAGES` — the same\n * set a console would seed — stamped for the site that deal names.\n *\n * Each stage carries its `forecastCategory` (AGL-3516) — the one a deal\n * landing in it takes — read as its kind's (won → `closed`, lost →\n * `omitted`, open → `pipeline`) on a stage saved before stages had one.\n * A pipeline seeded here gets Salesforce's standard Opportunity stages.\n *\n * An ARCHIVED pipeline (AGL-2620) is still read — the deals it closed name\n * it — but it takes no new deal: a create that names one is refused, and\n * the default is picked among the active ones.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmPipeline,\n createResourceUid,\n consentGroupForHost,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n dealStageForecastCategory,\n isPipelineArchived,\n} from '@aglyn/aglyn/server'\nimport { apiJson, ApiErrors } from '@aglyn/tenant-data-admin'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport {\n type ApiV1Context,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport { crmCollection, crmCreateStamp, crmTimes, isoFromMs, listCrm } from './crm-shared'\n\n/** Stages in pipeline order, whatever order the document holds them in. */\nexport function orderedStages(pipeline: Pick<CrmPipeline, 'stages'>): CrmDealStage[] {\n return [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n}\n\n/** The pipeline object as published. */\nexport function pipelineView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmPipeline>\n return {\n id: doc.id,\n object: 'pipeline',\n name: data.name ?? null,\n isDefault: data.isDefault === true,\n archived: isPipelineArchived(data),\n archivedAt: isPipelineArchived(data) ? isoFromMs(data.archivedAt) : null,\n stages: orderedStages({ stages: data.stages ?? [] }).map((stage) => ({\n id: stage.id,\n name: stage.name,\n order: stage.order,\n probability: stage.probability,\n kind: stage.kind,\n forecastCategory: dealStageForecastCategory(stage),\n })),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\n/** A pipeline as the deal handlers use it: its id beside its stages. */\nexport interface ResolvedPipeline {\n id: string\n pipeline: CrmPipeline\n}\n\n/**\n * The pipeline a deal lands in.\n *\n * Named → that one, or a refusal naming `pipelineId`. Unnamed → the default\n * among the pipelines VISIBLE FROM the deal's site, because a pipeline is\n * scoped like every CRM row and a deal in a pipeline its own site cannot\n * read is a deal the console renders with no stage. The visibility query is\n * one `array-contains-any` on the site's own tokens, and the default is\n * picked in memory: `isDefault` first, then the lowest id, so two consoles\n * seeding at once still agree. None at all → one is created here.\n */\nexport async function resolvePipeline(\n ctx: ApiV1Context,\n siteId: string,\n pipelineId: string | undefined,\n options: { allowArchived?: boolean } = {},\n): Promise<ResolvedPipeline | { error: string }> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.pipelines)\n if (pipelineId) {\n const snap = await collection.doc(pipelineId).get()\n if (!snap.exists) return { error: 'No such pipeline in this organization' }\n const pipeline = snap.data() as CrmPipeline\n if (!options.allowArchived && isPipelineArchived(pipeline)) {\n return { error: 'This pipeline is archived — create the deal in an active one' }\n }\n return { id: snap.id, pipeline }\n }\n const org = ctx.org as Record<string, unknown>\n const tokens = crmScopeTokens(org, consentGroupForHost(org, siteId))\n // Ordered, because a `limit()` with no `orderBy` is a random sample: fifty\n // is more pipelines than any org has, but the bound has to be a bound.\n const visible = await collection\n .where('visibleTo', 'array-contains-any', tokens)\n .orderBy(FieldPath.documentId())\n .limit(50)\n .get()\n const candidates = [...visible.docs]\n .filter((doc) => !isPipelineArchived(doc.data() as CrmPipeline))\n .sort((a, b) => a.id.localeCompare(b.id))\n const chosen =\n candidates.find((doc) => doc.get('isDefault') === true) ?? candidates[0]\n if (chosen) return { id: chosen.id, pipeline: chosen.data() as CrmPipeline }\n\n const id = createResourceUid()\n const seeded: CrmPipeline = {\n name: 'Sales',\n // A COPY: the module's default set must stay what the next seed starts\n // from once a merchant edits this pipeline's stages.\n stages: DEFAULT_DEAL_STAGES.map((stage) => ({ ...stage })),\n isDefault: true,\n archivedAt: null,\n ...crmCreateStamp(ctx, siteId),\n }\n await collection.doc(id).create(seeded)\n return { id, pipeline: seeded }\n}\n\nexport async function handlePipelines(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n if (request.method !== 'GET') {\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET' },\n })\n }\n const collection = crmCollection(ctx, CRM_COLLECTIONS.pipelines)\n const [, pipelineId] = segments\n if (!pipelineId) return listCrm(ctx, collection, url, [], pipelineView)\n const snap = await collection.doc(pipelineId).get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such pipeline', headers: ctx.headers })\n }\n return apiJson(pipelineView(snap), { headers: ctx.headers })\n}\n"],"names":["CRM_COLLECTIONS","createResourceUid","consentGroupForHost","crmScopeTokens","DEFAULT_DEAL_STAGES","dealStageForecastCategory","isPipelineArchived","apiJson","ApiErrors","FieldPath","requireScope","crmCollection","crmCreateStamp","crmTimes","isoFromMs","listCrm","orderedStages","pipeline","stages","sort","a","b","order","pipelineView","doc","data","id","object","name","isDefault","archived","archivedAt","map","stage","probability","kind","forecastCategory","siteId","hostId","resolvePipeline","ctx","pipelineId","options","candidates","collection","pipelines","snap","get","exists","error","allowArchived","org","tokens","visible","where","orderBy","documentId","limit","docs","filter","localeCompare","chosen","find","seeded","create","handlePipelines","request","segments","url","denied","method","methodNotAllowed","headers","Allow","notFound","message"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/plugins/crm/src/lib/server/api-v1/crm-pipelines.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * `/v1/pipelines` (AGL-2606) — the stages a deal moves through, read-only.\n *\n * A pipeline is how one business sells, and its stages are named by the\n * people who sell that way; the API reads them so a deal can be created in\n * the right one and never edits them. What it does do is SEED one: an\n * organization that has never opened the CRM has no pipeline, and a deal\n * cannot exist without a stage, so the first deal created without naming a\n * pipeline gets a default one built from `DEFAULT_DEAL_STAGES` — the same\n * set a console would seed — stamped for the site that deal names.\n *\n * Each stage carries its `forecastCategory` (AGL-3516) — the one a deal\n * landing in it takes — read as its kind's (won → `closed`, lost →\n * `omitted`, open → `pipeline`) on a stage saved before stages had one.\n * A pipeline seeded here gets Salesforce's standard Opportunity stages.\n *\n * An ARCHIVED pipeline (AGL-2620) is still read — the deals it closed name\n * it — but it takes no new deal: a create that names one is refused, and\n * the default is picked among the active ones.\n */\nimport {\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmPipeline,\n createResourceUid,\n consentGroupForHost,\n crmReadTokens,\n DEFAULT_DEAL_STAGES,\n dealStageForecastCategory,\n isPipelineArchived,\n ORG_SCOPE_TOKEN,\n} from '@aglyn/aglyn/server'\nimport { apiJson, ApiErrors } from '@aglyn/tenant-data-admin'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport {\n type ApiV1Context,\n requireScope,\n} from '@aglyn/tenant-data-admin/server/api-v1-kit'\nimport { crmCollection, crmCreateStamp, crmTimes, isoFromMs, listCrm } from './crm-shared'\n\n/** Stages in pipeline order, whatever order the document holds them in. */\nexport function orderedStages(pipeline: Pick<CrmPipeline, 'stages'>): CrmDealStage[] {\n return [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n}\n\n/** The pipeline object as published. */\nexport function pipelineView(doc: FirebaseFirestore.DocumentSnapshot) {\n const data = (doc.data() ?? {}) as Partial<CrmPipeline>\n return {\n id: doc.id,\n object: 'pipeline',\n name: data.name ?? null,\n isDefault: data.isDefault === true,\n archived: isPipelineArchived(data),\n archivedAt: isPipelineArchived(data) ? isoFromMs(data.archivedAt) : null,\n stages: orderedStages({ stages: data.stages ?? [] }).map((stage) => ({\n id: stage.id,\n name: stage.name,\n order: stage.order,\n probability: stage.probability,\n kind: stage.kind,\n forecastCategory: dealStageForecastCategory(stage),\n })),\n siteId: data.hostId ?? null,\n ...crmTimes(data as FirebaseFirestore.DocumentData),\n }\n}\n\n/** A pipeline as the deal handlers use it: its id beside its stages. */\nexport interface ResolvedPipeline {\n id: string\n pipeline: CrmPipeline\n}\n\n/**\n * The pipeline a deal lands in.\n *\n * Named → that one, or a refusal naming `pipelineId`. Unnamed → the default\n * among the pipelines VISIBLE FROM the deal's site, because a pipeline is\n * scoped like every CRM row and a deal in a pipeline its own site cannot\n * read is a deal the console renders with no stage. The visibility query is\n * one `array-contains-any` on the site's own tokens, and the default is\n * picked in memory: `isDefault` first, then the lowest id, so two consoles\n * seeding at once still agree. None at all → one is created here.\n */\nexport async function resolvePipeline(\n ctx: ApiV1Context,\n siteId: string | null,\n pipelineId: string | undefined,\n options: { allowArchived?: boolean } = {},\n): Promise<ResolvedPipeline | { error: string }> {\n const collection = crmCollection(ctx, CRM_COLLECTIONS.pipelines)\n if (pipelineId) {\n const snap = await collection.doc(pipelineId).get()\n if (!snap.exists) return { error: 'No such pipeline in this organization' }\n const pipeline = snap.data() as CrmPipeline\n if (!options.allowArchived && isPipelineArchived(pipeline)) {\n return { error: 'This pipeline is archived — create the deal in an active one' }\n }\n return { id: snap.id, pipeline }\n }\n const org = ctx.org as Record<string, unknown>\n // What the site READS, not what it would stamp: an org-wide pipeline is the\n // default for every site, and looking it up with a site's create tokens\n // missed it and seeded a second \"Sales\". A record from no site reads the\n // org's own.\n const tokens = siteId ? crmReadTokens(consentGroupForHost(org, siteId)) : [ORG_SCOPE_TOKEN]\n // Ordered, because a `limit()` with no `orderBy` is a random sample: fifty\n // is more pipelines than any org has, but the bound has to be a bound.\n const visible = await collection\n .where('visibleTo', 'array-contains-any', tokens)\n .orderBy(FieldPath.documentId())\n .limit(50)\n .get()\n const candidates = [...visible.docs]\n .filter((doc) => !isPipelineArchived(doc.data() as CrmPipeline))\n .sort((a, b) => a.id.localeCompare(b.id))\n const chosen =\n candidates.find((doc) => doc.get('isDefault') === true) ?? candidates[0]\n if (chosen) return { id: chosen.id, pipeline: chosen.data() as CrmPipeline }\n\n const id = createResourceUid()\n const seeded: CrmPipeline = {\n name: 'Sales',\n // A COPY: the module's default set must stay what the next seed starts\n // from once a merchant edits this pipeline's stages.\n stages: DEFAULT_DEAL_STAGES.map((stage) => ({ ...stage })),\n isDefault: true,\n archivedAt: null,\n ...crmCreateStamp(ctx, siteId),\n }\n await collection.doc(id).create(seeded)\n return { id, pipeline: seeded }\n}\n\nexport async function handlePipelines(\n request: Request,\n ctx: ApiV1Context,\n segments: string[],\n url: URL,\n): Promise<Response> {\n const denied = requireScope(ctx, 'crm:read')\n if (denied) return denied\n if (request.method !== 'GET') {\n return ApiErrors.methodNotAllowed({\n headers: { ...ctx.headers, Allow: 'GET' },\n })\n }\n const collection = crmCollection(ctx, CRM_COLLECTIONS.pipelines)\n const [, pipelineId] = segments\n if (!pipelineId) return listCrm(ctx, collection, url, [], pipelineView)\n const snap = await collection.doc(pipelineId).get()\n if (!snap.exists) {\n return ApiErrors.notFound({ message: 'No such pipeline', headers: ctx.headers })\n }\n return apiJson(pipelineView(snap), { headers: ctx.headers })\n}\n"],"names":["CRM_COLLECTIONS","createResourceUid","consentGroupForHost","crmReadTokens","DEFAULT_DEAL_STAGES","dealStageForecastCategory","isPipelineArchived","ORG_SCOPE_TOKEN","apiJson","ApiErrors","FieldPath","requireScope","crmCollection","crmCreateStamp","crmTimes","isoFromMs","listCrm","orderedStages","pipeline","stages","sort","a","b","order","pipelineView","doc","data","id","object","name","isDefault","archived","archivedAt","map","stage","probability","kind","forecastCategory","siteId","hostId","resolvePipeline","ctx","pipelineId","options","candidates","collection","pipelines","snap","get","exists","error","allowArchived","org","tokens","visible","where","orderBy","documentId","limit","docs","filter","localeCompare","chosen","find","seeded","create","handlePipelines","request","segments","url","denied","method","methodNotAllowed","headers","Allow","notFound","message"],"mappings":"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"}
|
|
@@ -75,12 +75,17 @@ export declare function crmRecordsBandRefusal(ctx: ApiV1Context): Promise<Respon
|
|
|
75
75
|
/**
|
|
76
76
|
* The `consentSiteId` a create carries, validated.
|
|
77
77
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* When given, it must be one of the organization's own sites, and the
|
|
79
|
+
* message names the field so an integrator sees the same key they would on a
|
|
80
|
+
* contact write. Whether it may be LEFT OUT is the org's CRM sharing: where
|
|
81
|
+
* every site sees the CRM's records (the default), the site is only the
|
|
82
|
+
* source a record came from, and a record created with none — `siteId:
|
|
83
|
+
* null` — belongs to the whole organization. Where the org keeps each site's
|
|
84
|
+
* records apart, the site decides who may see the record, so it stays
|
|
85
|
+
* required.
|
|
81
86
|
*/
|
|
82
87
|
export declare function readCrmSite(ctx: ApiV1Context, noun: string, body: Record<string, unknown>): {
|
|
83
|
-
siteId: string;
|
|
88
|
+
siteId: string | null;
|
|
84
89
|
} | {
|
|
85
90
|
response: Response;
|
|
86
91
|
};
|
|
@@ -89,16 +94,18 @@ export declare function readCrmSite(ctx: ApiV1Context, noun: string, body: Recor
|
|
|
89
94
|
*
|
|
90
95
|
* `visibleTo` is `crmScopeTokens` — the contact create path's own expression,
|
|
91
96
|
* which is what puts an API-created deal in exactly the scope a deal created
|
|
92
|
-
* from that site's console would land in
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
97
|
+
* from that site's console would land in — or the whole org for a record
|
|
98
|
+
* from no site, which `readCrmSite` admits only where every site sees the
|
|
99
|
+
* CRM's records. `hostId` is provenance and is never rewritten.
|
|
100
|
+
* `createdByUid` is the literal `'api'` because a key has no uid; it is the
|
|
101
|
+
* attribution the contact create makes through `sources.api`, in the field
|
|
102
|
+
* the CRM types already carry.
|
|
96
103
|
*
|
|
97
104
|
* Both timestamps are set here, once, so a fresh record's `updated` equals
|
|
98
105
|
* its `created` and an `updatedAfter` sweep that started before the create
|
|
99
106
|
* picks it up.
|
|
100
107
|
*/
|
|
101
|
-
export declare function crmCreateStamp(ctx: ApiV1Context, siteId: string): {
|
|
108
|
+
export declare function crmCreateStamp(ctx: ApiV1Context, siteId: string | null): {
|
|
102
109
|
visibleTo: import("@aglyn/aglyn/src").ScopeToken[];
|
|
103
110
|
hostId: string;
|
|
104
111
|
createdByUid: string;
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* would stamp it ({@link crmCreateStamp}). There is no default site for the
|
|
40
40
|
* reason the contacts write gives: picking the org's only site works until
|
|
41
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";
|
|
42
|
+
*/ import { CONTACT_FIELDS_MAX_PER_ORG, CRM_COLLECTIONS, CRM_LEAD_SOURCE_PICKLIST, consentGroupForHost, crmDefaultScopeOf, crmScopeTokens, ORG_SCOPE_TOKEN, effectiveCrmLeadSourcePicklist, readCrmCustomInput } from "@aglyn/aglyn/server";
|
|
43
43
|
import { ApiErrors, crmRecordsQuotaForOrg, decodeCursor, encodeCursor, listResponse, parseLimit } from "@aglyn/tenant-data-admin";
|
|
44
44
|
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
45
45
|
import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/server/api-v1-kit";
|
|
@@ -81,9 +81,14 @@ import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/serve
|
|
|
81
81
|
/**
|
|
82
82
|
* The `consentSiteId` a create carries, validated.
|
|
83
83
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
84
|
+
* When given, it must be one of the organization's own sites, and the
|
|
85
|
+
* message names the field so an integrator sees the same key they would on a
|
|
86
|
+
* contact write. Whether it may be LEFT OUT is the org's CRM sharing: where
|
|
87
|
+
* every site sees the CRM's records (the default), the site is only the
|
|
88
|
+
* source a record came from, and a record created with none — `siteId:
|
|
89
|
+
* null` — belongs to the whole organization. Where the org keeps each site's
|
|
90
|
+
* records apart, the site decides who may see the record, so it stays
|
|
91
|
+
* required.
|
|
87
92
|
*/ export function readCrmSite(ctx, noun, body) {
|
|
88
93
|
var _body_consentSiteId;
|
|
89
94
|
const refuse = (error)=>({
|
|
@@ -91,8 +96,11 @@ import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/serve
|
|
|
91
96
|
consentSiteId: error
|
|
92
97
|
})
|
|
93
98
|
});
|
|
94
|
-
if (body.consentSiteId === undefined) {
|
|
95
|
-
|
|
99
|
+
if (body.consentSiteId === undefined || body.consentSiteId === null) {
|
|
100
|
+
if (crmDefaultScopeOf(ctx.org) === 'org') return {
|
|
101
|
+
siteId: null
|
|
102
|
+
};
|
|
103
|
+
return refuse('Required — this organization keeps each site\'s records apart, so name the site this record belongs to');
|
|
96
104
|
}
|
|
97
105
|
const siteId = String((_body_consentSiteId = body.consentSiteId) != null ? _body_consentSiteId : '').trim();
|
|
98
106
|
if (!siteId) return refuse('Must name a site');
|
|
@@ -106,10 +114,12 @@ import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/serve
|
|
|
106
114
|
*
|
|
107
115
|
* `visibleTo` is `crmScopeTokens` — the contact create path's own expression,
|
|
108
116
|
* which is what puts an API-created deal in exactly the scope a deal created
|
|
109
|
-
* from that site's console would land in
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
117
|
+
* from that site's console would land in — or the whole org for a record
|
|
118
|
+
* from no site, which `readCrmSite` admits only where every site sees the
|
|
119
|
+
* CRM's records. `hostId` is provenance and is never rewritten.
|
|
120
|
+
* `createdByUid` is the literal `'api'` because a key has no uid; it is the
|
|
121
|
+
* attribution the contact create makes through `sources.api`, in the field
|
|
122
|
+
* the CRM types already carry.
|
|
113
123
|
*
|
|
114
124
|
* Both timestamps are set here, once, so a fresh record's `updated` equals
|
|
115
125
|
* its `created` and an `updatedAfter` sweep that started before the create
|
|
@@ -118,7 +128,9 @@ import { orgOwnsHost, paginate, serialize } from "@aglyn/tenant-data-admin/serve
|
|
|
118
128
|
const org = ctx.org;
|
|
119
129
|
const now = Timestamp.now();
|
|
120
130
|
return {
|
|
121
|
-
visibleTo: crmScopeTokens(org, consentGroupForHost(org, siteId))
|
|
131
|
+
visibleTo: siteId ? crmScopeTokens(org, consentGroupForHost(org, siteId)) : [
|
|
132
|
+
ORG_SCOPE_TOKEN
|
|
133
|
+
],
|
|
122
134
|
hostId: siteId,
|
|
123
135
|
createdByUid: 'api',
|
|
124
136
|
createdAt: now,
|
|
@@ -1 +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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"}
|
|
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 crmDefaultScopeOf,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\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 * When given, it must be one of the organization's own sites, and the\n * message names the field so an integrator sees the same key they would on a\n * contact write. Whether it may be LEFT OUT is the org's CRM sharing: where\n * every site sees the CRM's records (the default), the site is only the\n * source a record came from, and a record created with none — `siteId:\n * null` — belongs to the whole organization. Where the org keeps each site's\n * records apart, the site decides who may see the record, so it stays\n * required.\n */\nexport function readCrmSite(\n ctx: ApiV1Context,\n noun: string,\n body: Record<string, unknown>,\n): { siteId: string | null } | { response: Response } {\n const refuse = (error: string) => ({\n response: crmValidationFailed(ctx, noun, { consentSiteId: error }),\n })\n if (body.consentSiteId === undefined || body.consentSiteId === null) {\n if (crmDefaultScopeOf(ctx.org as Record<string, unknown>) === 'org') return { siteId: null }\n return refuse(\n 'Required — this organization keeps each site\\'s records apart, so name the site this record belongs to',\n )\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 — or the whole org for a record\n * from no site, which `readCrmSite` admits only where every site sees the\n * CRM's records. `hostId` is provenance and is never rewritten.\n * `createdByUid` is the literal `'api'` because a key has no uid; it is the\n * attribution the contact create makes through `sources.api`, in the field\n * 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 | null) {\n const org = ctx.org as Record<string, unknown>\n const now = Timestamp.now()\n return {\n visibleTo: siteId ? crmScopeTokens(org, consentGroupForHost(org, siteId)) : [ORG_SCOPE_TOKEN],\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","crmDefaultScopeOf","crmScopeTokens","ORG_SCOPE_TOKEN","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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"}
|
|
@@ -77,11 +77,11 @@ export const CRM_API_V1_DESCRIPTIONS = {
|
|
|
77
77
|
custom: openObjectField('Contact custom fields, keyed by field key.'),
|
|
78
78
|
mediaIds: stringListField('Media library files attached by the named site, by id, at most 20. An empty array clears them.')
|
|
79
79
|
},
|
|
80
|
-
writeNote: '`email` is accepted on create only — a `PATCH` that names it is a `400`, because a contact is identified by its email.',
|
|
80
|
+
writeNote: 'A create needs an `email`, a `name`, a `phone`, a `mobilePhone`, or a `firstName` or `lastName`. `email` is accepted on create only — a `PATCH` that names it is a `400`, because a contact is identified by its email.',
|
|
81
81
|
fields: _extends({
|
|
82
82
|
id: stringField('Contact id.'),
|
|
83
83
|
object: objectKindField('contact'),
|
|
84
|
-
email: stringField('Primary email
|
|
84
|
+
email: nullableField(stringField('Primary email, unique per organization, or `null` for a contact with no address yet.')),
|
|
85
85
|
name: stringField('Display name.'),
|
|
86
86
|
tags: stringListField('Free-form tags.'),
|
|
87
87
|
notes: stringField('Free-form notes.'),
|
|
@@ -780,7 +780,7 @@ export const CRM_API_V1_DESCRIPTIONS = {
|
|
|
780
780
|
at: isoField('When the interaction happened — not when it was logged.'),
|
|
781
781
|
byUid: nullableField(stringField('User who logged it.')),
|
|
782
782
|
contactId: nullableField(stringField('Associated contact.')),
|
|
783
|
-
companyId: nullableField(stringField('Associated company.')),
|
|
783
|
+
companyId: nullableField(stringField('Associated company. When none is sent, the company of the deal, else of the contact.')),
|
|
784
784
|
dealId: nullableField(stringField('Associated deal.')),
|
|
785
785
|
outcome: nullableField(stringField('Outcome label.')),
|
|
786
786
|
durationMinutes: nullableField(integerField('Duration, for calls and meetings.')),
|