@aglyn/plugins-crm 1.0.0-beta.244 → 1.0.0-beta.246
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/README.md +2 -0
- package/package.json +14 -14
- package/src/lib/components/contact-field-drawer.js +4 -14
- package/src/lib/components/contact-field-drawer.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +5 -0
- package/src/lib/declarations.console-server.js +34 -0
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/model/crm-record-routes.js +12 -0
- package/src/lib/model/crm-record-routes.js.map +1 -1
- package/src/lib/model/crm-setup-content.d.ts +196 -0
- package/src/lib/model/crm-setup-content.js +416 -0
- package/src/lib/model/crm-setup-content.js.map +1 -0
- package/src/lib/server/crm-setup-ai-capabilities.d.ts +55 -0
- package/src/lib/server/crm-setup-ai-capabilities.js +198 -0
- package/src/lib/server/crm-setup-ai-capabilities.js.map +1 -0
- package/src/lib/server/crm-setup-writers.server.d.ts +92 -0
- package/src/lib/server/crm-setup-writers.server.js +315 -0
- package/src/lib/server/crm-setup-writers.server.js.map +1 -0
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ The CRM is console-only. Its records live in Firestore and it has no canvas comp
|
|
|
40
40
|
|
|
41
41
|
`registerCrmConsoleApi()` from `@aglyn/plugins-crm/server` (the `consoleApi` surface) registers routes under the `crm` prefix for contact stage and field updates, contact creation and merge, lead conversion, deal stages, tasks, one-to-one email and a contact's email history, erasing a person, organization-level activity, recipes, and the workspace's inbound email capture address. Contacts, companies, leads, deals and tasks are imported and exported through the platform's transfer framework, and activities, pipelines and custom fields exported: `registerCrmConsoleServerDeclarations()` registers each resource's server half (`src/lib/transfer/`) and the console registrar its name in the import wizard and export dialog. It also registers the CRM on two core seams, so another plugin can read a record's facts or file an item on a record's timeline without importing this package.
|
|
42
42
|
|
|
43
|
+
At the console's boot it also registers the CRM's setup writers on the `plugin-resource-drafts` seam (`crm-pipeline`, `crm-field`) and the build operations Aglyn AI plans with them (`plugin-ai-capabilities`): a pipeline with its stages and a custom field, saved live under the console's rules (`src/lib/model/crm-setup-content.ts`, `src/lib/server/crm-setup-writers.server.ts`).
|
|
44
|
+
|
|
43
45
|
At the console's boot (`registerCrmConsoleServerDeclarations`) it registers a membership detacher (`plugin-membership-detach`): when the plugin that keeps a container — a campaign — removes it, the container comes off every lead and every contact facet that names it, and the records stay.
|
|
44
46
|
|
|
45
47
|
### Entry points
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-crm",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.246",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-ui-next": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
36
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
37
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
38
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
39
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
40
|
-
"@aglyn/tenant-runtime": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.246",
|
|
29
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.246",
|
|
30
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.246",
|
|
31
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.246",
|
|
32
|
+
"@aglyn/shared-ui-next": "1.0.0-beta.246",
|
|
33
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.246",
|
|
34
|
+
"@aglyn/shared-util-email": "1.0.0-beta.246",
|
|
35
|
+
"@aglyn/shared-util-http": "1.0.0-beta.246",
|
|
36
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.246",
|
|
37
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.246",
|
|
38
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.246",
|
|
39
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.246",
|
|
40
|
+
"@aglyn/tenant-runtime": "1.0.0-beta.246",
|
|
41
41
|
"@dnd-kit/core": "^6.3.1",
|
|
42
42
|
"@dnd-kit/utilities": "^3.2.2",
|
|
43
43
|
"@swc/helpers": "0.5.23",
|
|
@@ -18,6 +18,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
18
18
|
import { CONTACT_FIELD_TYPE_LABELS, CONTACT_FIELD_TYPES, isContactFieldType, normalizeContactFieldKey } from "@aglyn/aglyn";
|
|
19
19
|
import { Button, Drawer, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
|
20
20
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
21
|
+
import { CRM_FIELD_LABEL_MAX_LENGTH, crmFieldOptions } from "../model/crm-setup-content.js";
|
|
21
22
|
/** How the object reads in the drawer's own sentences — singular, lowercase. */ const OBJECT_NOUN = {
|
|
22
23
|
contact: 'contact',
|
|
23
24
|
company: 'company',
|
|
@@ -46,20 +47,9 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
46
47
|
const filled = IMPORTABLE[object] ? 'is filled on the record, by CSV import or over the API.' : 'is filled on the record or over the API.';
|
|
47
48
|
return `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` + `field ${filled}`;
|
|
48
49
|
}
|
|
49
|
-
/** How
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */ function parseOptions(text) {
|
|
53
|
-
const seen = new Set();
|
|
54
|
-
const options = [];
|
|
55
|
-
for (const line of text.split('\n')){
|
|
56
|
-
const option = line.trim().slice(0, OPTION_MAX_LENGTH);
|
|
57
|
-
if (!option || seen.has(option)) continue;
|
|
58
|
-
seen.add(option);
|
|
59
|
-
options.push(option);
|
|
60
|
-
if (options.length >= OPTIONS_MAX) break;
|
|
61
|
-
}
|
|
62
|
-
return options;
|
|
50
|
+
/** How long a label may be: the one bound an AI build's field is held to too (AGL-3746). */ const LABEL_MAX_LENGTH = CRM_FIELD_LABEL_MAX_LENGTH;
|
|
51
|
+
/** One choice per line, trimmed, blanks dropped, duplicates collapsed, under the shared bounds. */ function parseOptions(text) {
|
|
52
|
+
return crmFieldOptions(text.split('\n'));
|
|
63
53
|
}
|
|
64
54
|
/**
|
|
65
55
|
* The one form a custom contact field is created and edited through
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"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'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How many choices a select may declare, and how long each may be. */\nconst OPTIONS_MAX = 50\nconst OPTION_MAX_LENGTH = 120\nconst LABEL_MAX_LENGTH = 80\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed. */\nfunction parseOptions(text: string): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const line of text.split('\\n')) {\n const option = line.trim().slice(0, OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= OPTIONS_MAX) break\n }\n return options\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","OPTIONS_MAX","OPTION_MAX_LENGTH","LABEL_MAX_LENGTH","parseOptions","text","seen","Set","options","line","split","option","trim","slice","has","add","push","length","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","handleSubmit","caught","console","anchor","spacing","sx","width","maxWidth","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/components/contact-field-drawer.tsx"],"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'use client'\n\nimport {\n CONTACT_FIELD_TYPE_LABELS,\n CONTACT_FIELD_TYPES,\n type ContactFieldType,\n type CrmFieldObject,\n isContactFieldType,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn'\nimport {\n Button,\n Drawer,\n FormControlLabel,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { ContactFieldDefinitionDoc } from '../hooks/use-contact-field-definitions'\nimport { CRM_FIELD_LABEL_MAX_LENGTH, crmFieldOptions } from '../model/crm-setup-content'\n\n/** What the drawer hands back — the definition's editable half. */\nexport interface ContactFieldDraft {\n label: string\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty for every other type. */\n options: string[]\n required: boolean\n}\n\nexport interface ContactFieldDrawerProps {\n open: boolean\n onClose: () => void\n /** The definition being edited, or `null` to create one. */\n definition: ContactFieldDefinitionDoc | null\n /**\n * Every key the org has already used, retired ones included. A key is\n * the map key values are stored under, so a retired field's key is still\n * taken: a new field reusing it would read the old values back as its own.\n */\n takenKeys: readonly string[]\n /** Receives the draft; closing on success is the caller's job. */\n onSubmit: (draft: ContactFieldDraft) => Promise<void> | void\n /**\n * The record the field describes (AGL-2661) — the Fields section's\n * current tab. Contacts when unsaid. The drawer's copy follows it, and it\n * is the one thing here a form-field mapping cares about: a website form\n * saves into a CONTACT field only, because a submission is a fact about\n * a person, so the other objects' drawers say so instead of offering it.\n */\n object?: CrmFieldObject\n}\n\n/** How the object reads in the drawer's own sentences — singular, lowercase. */\nconst OBJECT_NOUN: Record<CrmFieldObject, string> = {\n contact: 'contact',\n company: 'company',\n deal: 'deal',\n lead: 'lead',\n}\n\n/**\n * Which objects a CSV import can fill a custom field on — see\n * `CUSTOM_FIELD_OBJECT` in `record-facts.ts`, which decides what the\n * mapping menu offers. The deal and lead files carry the standard columns\n * only, so their drawers must not promise an import that would silently\n * drop the column.\n */\nconst IMPORTABLE: Record<CrmFieldObject, boolean> = {\n contact: true,\n company: true,\n deal: false,\n lead: false,\n}\n\n/**\n * What the drawer says about website forms, per object — see\n * {@link ContactFieldDrawerProps.object}.\n */\nexport function fieldDrawerMappingNote(object: CrmFieldObject): string {\n if (object === 'contact') {\n return 'A website form field can save its answer into this field.'\n }\n const filled = IMPORTABLE[object]\n ? 'is filled on the record, by CSV import or over the API.'\n : 'is filled on the record or over the API.'\n return (\n `A website form field saves into contact fields only; a ${OBJECT_NOUN[object]} ` +\n `field ${filled}`\n )\n}\n\n/** How long a label may be: the one bound an AI build's field is held to too (AGL-3746). */\nconst LABEL_MAX_LENGTH = CRM_FIELD_LABEL_MAX_LENGTH\n\n/** One choice per line, trimmed, blanks dropped, duplicates collapsed, under the shared bounds. */\nfunction parseOptions(text: string): string[] {\n return crmFieldOptions(text.split('\\n'))\n}\n\n/**\n * The one form a custom contact field is created and edited through\n * (AGL-2601).\n *\n * ## The key follows the label until the author takes it over\n *\n * A merchant types \"Annual revenue\" and should not have to know that the\n * value is stored under `annual_revenue`. So the key is derived from the\n * label as they type — `normalizeContactFieldKey` — and shown beside it, and\n * only becomes the author's own the moment they edit it. That is the whole\n * of the grammar they are asked to learn, and the field is still there for\n * the author who wants `arr` instead.\n *\n * ## Two things do not change after creation\n *\n * The KEY is the map key every value is stored under, so changing it would\n * orphan every value already written; the TYPE is what every reader coerces\n * those values by, so changing it would make them unreadable. Both are\n * disabled on an existing field and say why. A rename is a new field and a\n * retire, which the section's list offers.\n */\nexport function ContactFieldDrawer(props: ContactFieldDrawerProps) {\n const { open, onClose, definition, takenKeys, onSubmit, object = 'contact' } = props\n const editing = Boolean(definition)\n const noun = OBJECT_NOUN[object]\n\n const [label, setLabel] = useState('')\n const [key, setKey] = useState('')\n const [keyTouched, setKeyTouched] = useState(false)\n const [type, setType] = useState<ContactFieldType>('text')\n const [optionsText, setOptionsText] = useState('')\n const [required, setRequired] = useState(false)\n const [busy, setBusy] = useState(false)\n const [error, setError] = useState<string | null>(null)\n\n // Seeded on every open, from the definition when there is one: a drawer\n // that kept the previous field's draft would offer it as the next one.\n useEffect(() => {\n if (!open) return\n setLabel(definition?.label ?? '')\n setKey(definition?.key ?? '')\n setKeyTouched(Boolean(definition))\n setType(definition?.type ?? 'text')\n setOptionsText((definition?.options ?? []).join('\\n'))\n setRequired(definition?.required === true)\n setBusy(false)\n setError(null)\n }, [open, definition])\n\n const derivedKey = useMemo(\n () => (keyTouched ? normalizeContactFieldKey(key) : normalizeContactFieldKey(label)),\n [keyTouched, key, label],\n )\n const shownKey = keyTouched ? key : (derivedKey ?? '')\n const keyTaken =\n !editing && derivedKey != null && takenKeys.includes(derivedKey)\n const options = useMemo(() => parseOptions(optionsText), [optionsText])\n\n const keyHelp = editing\n ? 'Values are stored under this key, so it cannot change once the field exists.'\n : keyTaken\n ? 'That key is already in use — a retired field keeps its key.'\n : shownKey && !derivedKey\n ? 'A key starts with a letter and uses letters, digits and underscores.'\n : 'Derived from the label; edit it to choose your own.'\n\n const canSubmit =\n !busy &&\n label.trim().length > 0 &&\n derivedKey != null &&\n !keyTaken &&\n (type !== 'select' || options.length > 0)\n\n const handleSubmit = useCallback(async () => {\n if (!canSubmit || derivedKey == null) return\n setBusy(true)\n setError(null)\n try {\n await onSubmit({\n label: label.trim().slice(0, LABEL_MAX_LENGTH),\n key: derivedKey,\n type,\n options: type === 'select' ? options : [],\n required,\n })\n } catch (caught) {\n console.error(caught)\n setError('Could not save the field. Try again.')\n } finally {\n setBusy(false)\n }\n }, [canSubmit, derivedKey, onSubmit, label, type, options, required])\n\n return (\n <Drawer anchor=\"right\" open={open} onClose={onClose}>\n <Stack spacing={2} sx={{ width: 360, maxWidth: '100vw', p: 3 }}>\n <Typography variant=\"h6\">\n {editing ? `Edit ${noun} field` : `New ${noun} field`}\n </Typography>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {fieldDrawerMappingNote(object)}\n </Typography>\n <TextField\n size=\"small\"\n label=\"Label\"\n value={label}\n onChange={(event) => setLabel(event.target.value)}\n slotProps={{ htmlInput: { maxLength: LABEL_MAX_LENGTH } }}\n autoFocus={!editing}\n fullWidth\n />\n <TextField\n size=\"small\"\n label=\"Key\"\n value={shownKey}\n onChange={(event) => {\n setKeyTouched(true)\n setKey(event.target.value)\n }}\n disabled={editing}\n error={!editing && (keyTaken || (shownKey.length > 0 && !derivedKey))}\n helperText={keyHelp}\n slotProps={{ htmlInput: { maxLength: 40, spellCheck: false } }}\n fullWidth\n />\n <TextField\n select\n size=\"small\"\n label=\"Type\"\n value={type}\n onChange={(event) => {\n const next = event.target.value\n if (isContactFieldType(next)) setType(next)\n }}\n disabled={editing}\n helperText={\n editing\n ? 'Values are read by type, so it is fixed once the field exists.'\n : undefined\n }\n fullWidth\n >\n {CONTACT_FIELD_TYPES.map((option) => (\n <MenuItem key={option} value={option}>\n {CONTACT_FIELD_TYPE_LABELS[option]}\n </MenuItem>\n ))}\n </TextField>\n {type === 'select' ? (\n <TextField\n size=\"small\"\n label=\"Choices\"\n value={optionsText}\n onChange={(event) => setOptionsText(event.target.value)}\n helperText=\"One per line. A stored value has to be one of these.\"\n multiline\n minRows={3}\n fullWidth\n />\n ) : null}\n <FormControlLabel\n control={\n <Switch\n checked={required}\n onChange={(event) => setRequired(event.target.checked)}\n />\n }\n label={`Required on the ${noun} form`}\n />\n {error ? (\n <Typography variant=\"body2\" color=\"error\">\n {error}\n </Typography>\n ) : null}\n <Stack direction=\"row\" spacing={1}>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!canSubmit}\n onClick={handleSubmit}\n >\n {editing ? 'Save' : 'Create field'}\n </Button>\n <Button onClick={onClose} disabled={busy}>\n {'Cancel'}\n </Button>\n </Stack>\n </Stack>\n </Drawer>\n )\n}\nContactFieldDrawer.displayName = 'ContactFieldDrawer'\n\nexport default ContactFieldDrawer\n"],"names":["CONTACT_FIELD_TYPE_LABELS","CONTACT_FIELD_TYPES","isContactFieldType","normalizeContactFieldKey","Button","Drawer","FormControlLabel","MenuItem","Stack","Switch","TextField","Typography","useCallback","useEffect","useMemo","useState","CRM_FIELD_LABEL_MAX_LENGTH","crmFieldOptions","OBJECT_NOUN","contact","company","deal","lead","IMPORTABLE","fieldDrawerMappingNote","object","filled","LABEL_MAX_LENGTH","parseOptions","text","split","ContactFieldDrawer","props","open","onClose","definition","takenKeys","onSubmit","editing","Boolean","noun","label","setLabel","key","setKey","keyTouched","setKeyTouched","type","setType","optionsText","setOptionsText","required","setRequired","busy","setBusy","error","setError","options","join","derivedKey","shownKey","keyTaken","includes","keyHelp","canSubmit","trim","length","handleSubmit","slice","caught","console","anchor","spacing","sx","width","maxWidth","p","variant","color","size","value","onChange","event","target","slotProps","htmlInput","maxLength","autoFocus","fullWidth","disabled","helperText","spellCheck","select","next","undefined","map","option","multiline","minRows","control","checked","direction","onClick","displayName"],"mappings":"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"}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import { type PluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
18
|
+
/** The `crm-pipeline` writer as boot registers it. */
|
|
19
|
+
export declare const lazyCrmPipelineSetupWriter: PluginResourceDraftWriter;
|
|
20
|
+
/** The `crm-field` writer as boot registers it. */
|
|
21
|
+
export declare const lazyCrmFieldSetupWriter: PluginResourceDraftWriter;
|
|
17
22
|
/**
|
|
18
23
|
* The CRM's CONSOLE-ONLY server declarations, named under
|
|
19
24
|
* `consoleServerDeclarations` in `plugins.config.json` and run at the
|
|
@@ -19,9 +19,33 @@ import { registerApiV1Resource, registerApiV1UsageFigures } from "@aglyn/tenant-
|
|
|
19
19
|
import { listPluginMembershipDetachers, registerPluginMembershipDetacher } from "@aglyn/aglyn/plugin-manager/plugin-membership-detach";
|
|
20
20
|
import { registerPluginPersonRecordsEraser } from "@aglyn/aglyn/plugin-manager/plugin-person-erasure";
|
|
21
21
|
import { registerPluginTransferResource } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
|
|
22
|
+
import { registerPluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
|
|
22
23
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
24
|
+
import { checkCrmFieldSetup, checkCrmPipelineSetup, CRM_FIELD_SETUP_RESOURCE, CRM_PIPELINE_SETUP_RESOURCE } from "./model/crm-setup-content.js";
|
|
25
|
+
import { registerCrmSetupAiCapabilities } from "./server/crm-setup-ai-capabilities.js";
|
|
23
26
|
import { crmTransferPlanGate, registerCrmTransferResources } from "./transfer/register.js";
|
|
24
27
|
import { CRM_EMAIL_TEMPLATE_PACKAGE_RULES, CRM_EMAIL_TEMPLATES_TRANSFER_KEY, crmEmailTemplateDependencies, remapCrmEmailTemplateIds } from "./transfer/email-templates-package.js";
|
|
28
|
+
let setupLoading = null;
|
|
29
|
+
/** The CRM's setup writers over the Admin SDK (AGL-3746), loaded on their first read or write. */ function setupWriters() {
|
|
30
|
+
setupLoading != null ? setupLoading : setupLoading = import("./server/crm-setup-writers.server.js").catch((error)=>{
|
|
31
|
+
setupLoading = null;
|
|
32
|
+
throw error;
|
|
33
|
+
});
|
|
34
|
+
return setupLoading;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A setup writer as boot registers it: the check is pure and answers at once;
|
|
38
|
+
* the hooks that read or write load the Admin SDK half on first use.
|
|
39
|
+
*/ function lazySetupWriter(check, writer) {
|
|
40
|
+
return {
|
|
41
|
+
check: (content)=>check(content),
|
|
42
|
+
refusal: async (context)=>writer(await setupWriters()).refusal(context),
|
|
43
|
+
read: async (context)=>writer(await setupWriters()).read(context),
|
|
44
|
+
write: async (request)=>writer(await setupWriters()).write(request)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The `crm-pipeline` writer as boot registers it. */ export const lazyCrmPipelineSetupWriter = lazySetupWriter(checkCrmPipelineSetup, (module)=>module.crmPipelineSetupWriter);
|
|
48
|
+
/** The `crm-field` writer as boot registers it. */ export const lazyCrmFieldSetupWriter = lazySetupWriter(checkCrmFieldSetup, (module)=>module.crmFieldSetupWriter);
|
|
25
49
|
/** The email templates package's server half (AGL-3535), loaded when an import or export first asks. */ const templatesPackage = async ()=>(await import("./transfer/email-templates-package.server.js")).createCrmEmailTemplatesPackage();
|
|
26
50
|
/**
|
|
27
51
|
* The plan feature every CRM resource of `/v1` needs, and the sentence its
|
|
@@ -135,6 +159,16 @@ import { CRM_EMAIL_TEMPLATE_PACKAGE_RULES, CRM_EMAIL_TEMPLATES_TRANSFER_KEY, crm
|
|
|
135
159
|
registerApiV1UsageFigures(async (ctx)=>(await import("./server/api-v1/usage.js")).crmUsageFigures(ctx), {
|
|
136
160
|
pluginId: BUNDLE_ID
|
|
137
161
|
});
|
|
162
|
+
// The CRM's setup an AI build asks for (AGL-3746): a pipeline and a custom
|
|
163
|
+
// field, saved by these writers under the CRM's rules, and the operations
|
|
164
|
+
// the build plans for them. The console runs AI jobs (AGL-3026).
|
|
165
|
+
registerPluginResourceDraftWriter(CRM_PIPELINE_SETUP_RESOURCE, lazyCrmPipelineSetupWriter, {
|
|
166
|
+
pluginId: BUNDLE_ID
|
|
167
|
+
});
|
|
168
|
+
registerPluginResourceDraftWriter(CRM_FIELD_SETUP_RESOURCE, lazyCrmFieldSetupWriter, {
|
|
169
|
+
pluginId: BUNDLE_ID
|
|
170
|
+
});
|
|
171
|
+
registerCrmSetupAiCapabilities();
|
|
138
172
|
// What the CRM imports and exports (AGL-3527): each resource's module
|
|
139
173
|
// loads with the first transfer that asks for it.
|
|
140
174
|
registerCrmTransferResources(BUNDLE_ID);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.console-server.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// The registry's own module, not the data layer's barrel: boot needs one\n// registry, not the whole server surface.\nimport {\n type ApiV1ResourceHandler,\n registerApiV1Resource,\n registerApiV1UsageFigures,\n} from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport {\n listPluginMembershipDetachers,\n registerPluginMembershipDetacher,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { registerPluginPersonRecordsEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { registerPluginTransferResource } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { crmTransferPlanGate, registerCrmTransferResources } from './transfer/register'\nimport {\n CRM_EMAIL_TEMPLATE_PACKAGE_RULES,\n CRM_EMAIL_TEMPLATES_TRANSFER_KEY,\n crmEmailTemplateDependencies,\n remapCrmEmailTemplateIds,\n type CrmEmailTemplatePackageContent,\n} from './transfer/email-templates-package'\n\n/** The email templates package's server half (AGL-3535), loaded when an import or export first asks. */\nconst templatesPackage = async () =>\n (await import('./transfer/email-templates-package.server')).createCrmEmailTemplatesPackage()\n\n/**\n * The plan feature every CRM resource of `/v1` needs, and the sentence its\n * refusal says (AGL-2611, AGL-2851): the whole CRM is included from\n * Starter, so a Free workspace reads and changes none of it over the API. A\n * capture still records people on every plan; working them is the CRM.\n */\nconst CRM_SUITE = {\n feature: 'crm',\n message:\n 'The CRM — contacts, leads, companies, deals, tasks, pipelines, ' +\n 'activities and email templates — is not included in this organization’s plan',\n} as const\n\n/**\n * The CRM's resources on the customer REST API, each handler loaded with the\n * first request for it. In the order the API root lists them: the people\n * first, then the records a team keeps about them, then a site's leads\n * (`/v1/leads?siteId=`, a top-level path with the site as a parameter), then\n * the letters sent from a record (AGL-2658).\n */\nconst CRM_API_V1_RESOURCES: ReadonlyArray<readonly [string, ApiV1ResourceHandler]> = [\n ['contacts', async (...args) => (await import('./server/api-v1/contacts')).handleContacts(...args)],\n ['companies', async (...args) => (await import('./server/api-v1/crm-companies')).handleCompanies(...args)],\n ['pipelines', async (...args) => (await import('./server/api-v1/crm-pipelines')).handlePipelines(...args)],\n ['deals', async (...args) => (await import('./server/api-v1/crm-deals')).handleDeals(...args)],\n ['tasks', async (...args) => (await import('./server/api-v1/crm-tasks')).handleTasks(...args)],\n ['activities', async (...args) => (await import('./server/api-v1/crm-activities')).handleActivities(...args)],\n ['leads', async (...args) => (await import('./server/api-v1/crm-leads')).handleLeads(...args)],\n [\n 'email-templates',\n async (...args) => (await import('./server/api-v1/crm-email-templates')).handleEmailTemplates(...args),\n ],\n]\n\n/**\n * The CRM's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * It serves the CRM's resources of `/v1` (AGL-2606): the console's router\n * owns the pipeline in front of them — the key, the plan's API access, the\n * quota, the rate limit, the error envelope — and hands every request under\n * `/v1/contacts`, `/v1/deals` and the rest to the CRM once the key is\n * authenticated and the organization's plan carries the CRM. Each resource\n * brings its description for the API's OpenAPI document, and the CRM's\n * records band and collection sizes join `GET /v1/usage`. The API is the\n * console's alone, so the tenant runtime does not register them.\n *\n * It registers the server half of the CRM's import and export resources\n * (`transfer/register.ts`).\n *\n * It also clears a removed container off the CRM's records: a campaign is\n * deleted from the console, and its owner asks every plugin's membership\n * detacher first (`server/container-detach.ts`).\n *\n * Light at boot: each resource's module is imported with its first request,\n * the descriptions when the document is first built, and the usage reader\n * with the first usage call.\n * Registering again replaces this plugin's own entries, so a second call (a\n * hot reload, a spec) is harmless.\n */\nexport function registerCrmConsoleServerDeclarations(): void {\n // Email templates in a workspace package (AGL-3535).\n registerPluginTransferResource(\n CRM_EMAIL_TEMPLATES_TRANSFER_KEY,\n {\n items: async (ctx) => (await templatesPackage()).items(ctx),\n dependencies: (item) => crmEmailTemplateDependencies(item as CrmEmailTemplatePackageContent),\n remapIds: (item, idMap) => remapCrmEmailTemplateIds(item as CrmEmailTemplatePackageContent, idMap),\n readItems: async (ctx, ids) => (await templatesPackage()).readItems(ctx, ids),\n writeItems: async (ctx, items, writer) => (await templatesPackage()).writeItems(ctx, items as never, writer),\n revertItems: async (ctx, steps) => (await templatesPackage()).revertItems(ctx, steps as never),\n problems: async (ctx, write) => (await templatesPackage()).problems(ctx, write as never),\n rules: CRM_EMAIL_TEMPLATE_PACKAGE_RULES,\n planGate: crmTransferPlanGate('email templates'),\n },\n { pluginId: BUNDLE_ID },\n )\n // The record system's share of a person erasure (AGL-2623, AGL-3080): it\n // names the person's contacts before anybody erases, and erases them, their\n // satellites and their lead after everybody else. Required, and loaded with\n // the first erasure.\n registerPluginPersonRecordsEraser(\n {\n locate: async (target) => (await import('./server/person-eraser')).crmPersonEraser().locate(target),\n erase: async (request) => (await import('./server/person-eraser')).crmPersonEraser().erase(request),\n },\n { pluginId: BUNDLE_ID },\n )\n for (const [resource, handle] of CRM_API_V1_RESOURCES) {\n registerApiV1Resource(\n resource,\n {\n handle,\n entitlement: CRM_SUITE,\n describe: async () =>\n (await import('./server/api-v1/openapi')).CRM_API_V1_DESCRIPTIONS[resource],\n },\n { pluginId: BUNDLE_ID },\n )\n }\n registerApiV1UsageFigures(\n async (ctx) => (await import('./server/api-v1/usage')).crmUsageFigures(ctx),\n { pluginId: BUNDLE_ID },\n )\n // What the CRM imports and exports (AGL-3527): each resource's module\n // loads with the first transfer that asks for it.\n registerCrmTransferResources(BUNDLE_ID)\n // A removed container — a deleted campaign — comes off every lead and every\n // contact facet naming it, before its owner removes it (AGL-3080).\n if (!listPluginMembershipDetachers().includes(BUNDLE_ID)) {\n registerPluginMembershipDetacher(\n async (request) => (await import('./server/container-detach')).crmContainerDetacher(request),\n { pluginId: BUNDLE_ID },\n )\n }\n}\n"],"names":["registerApiV1Resource","registerApiV1UsageFigures","listPluginMembershipDetachers","registerPluginMembershipDetacher","registerPluginPersonRecordsEraser","registerPluginTransferResource","BUNDLE_ID","crmTransferPlanGate","registerCrmTransferResources","CRM_EMAIL_TEMPLATE_PACKAGE_RULES","CRM_EMAIL_TEMPLATES_TRANSFER_KEY","crmEmailTemplateDependencies","remapCrmEmailTemplateIds","templatesPackage","createCrmEmailTemplatesPackage","CRM_SUITE","feature","message","CRM_API_V1_RESOURCES","args","handleContacts","handleCompanies","handlePipelines","handleDeals","handleTasks","handleActivities","handleLeads","handleEmailTemplates","registerCrmConsoleServerDeclarations","items","ctx","dependencies","item","remapIds","idMap","readItems","ids","writeItems","writer","revertItems","steps","problems","write","rules","planGate","pluginId","locate","target","crmPersonEraser","erase","request","resource","handle","entitlement","describe","CRM_API_V1_DESCRIPTIONS","crmUsageFigures","includes","crmContainerDetacher"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.console-server.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// The registry's own module, not the data layer's barrel: boot needs one\n// registry, not the whole server surface.\nimport {\n type ApiV1ResourceHandler,\n registerApiV1Resource,\n registerApiV1UsageFigures,\n} from '@aglyn/tenant-data-admin/server/api-v1-resources'\nimport {\n listPluginMembershipDetachers,\n registerPluginMembershipDetacher,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { registerPluginPersonRecordsEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { registerPluginTransferResource } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport {\n registerPluginResourceDraftWriter,\n type PluginResourceDraftWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport {\n checkCrmFieldSetup,\n checkCrmPipelineSetup,\n CRM_FIELD_SETUP_RESOURCE,\n CRM_PIPELINE_SETUP_RESOURCE,\n} from './model/crm-setup-content'\nimport { registerCrmSetupAiCapabilities } from './server/crm-setup-ai-capabilities'\nimport { crmTransferPlanGate, registerCrmTransferResources } from './transfer/register'\nimport {\n CRM_EMAIL_TEMPLATE_PACKAGE_RULES,\n CRM_EMAIL_TEMPLATES_TRANSFER_KEY,\n crmEmailTemplateDependencies,\n remapCrmEmailTemplateIds,\n type CrmEmailTemplatePackageContent,\n} from './transfer/email-templates-package'\n\ntype CrmSetupWriters = typeof import('./server/crm-setup-writers.server')\nlet setupLoading: Promise<CrmSetupWriters> | null = null\n\n/** The CRM's setup writers over the Admin SDK (AGL-3746), loaded on their first read or write. */\nfunction setupWriters(): Promise<CrmSetupWriters> {\n setupLoading ??= import('./server/crm-setup-writers.server').catch((error: unknown) => {\n setupLoading = null\n throw error\n })\n return setupLoading\n}\n\n/**\n * A setup writer as boot registers it: the check is pure and answers at once;\n * the hooks that read or write load the Admin SDK half on first use.\n */\nfunction lazySetupWriter(\n check: (content: Readonly<Record<string, unknown>>) => ReturnType<PluginResourceDraftWriter['check']>,\n writer: (module: CrmSetupWriters) => PluginResourceDraftWriter,\n): PluginResourceDraftWriter {\n return {\n check: (content) => check(content),\n refusal: async (context) => writer(await setupWriters()).refusal(context),\n read: async (context) => writer(await setupWriters()).read(context),\n write: async (request) => writer(await setupWriters()).write(request),\n }\n}\n\n/** The `crm-pipeline` writer as boot registers it. */\nexport const lazyCrmPipelineSetupWriter = lazySetupWriter(\n checkCrmPipelineSetup,\n (module) => module.crmPipelineSetupWriter,\n)\n\n/** The `crm-field` writer as boot registers it. */\nexport const lazyCrmFieldSetupWriter = lazySetupWriter(\n checkCrmFieldSetup,\n (module) => module.crmFieldSetupWriter,\n)\n\n/** The email templates package's server half (AGL-3535), loaded when an import or export first asks. */\nconst templatesPackage = async () =>\n (await import('./transfer/email-templates-package.server')).createCrmEmailTemplatesPackage()\n\n/**\n * The plan feature every CRM resource of `/v1` needs, and the sentence its\n * refusal says (AGL-2611, AGL-2851): the whole CRM is included from\n * Starter, so a Free workspace reads and changes none of it over the API. A\n * capture still records people on every plan; working them is the CRM.\n */\nconst CRM_SUITE = {\n feature: 'crm',\n message:\n 'The CRM — contacts, leads, companies, deals, tasks, pipelines, ' +\n 'activities and email templates — is not included in this organization’s plan',\n} as const\n\n/**\n * The CRM's resources on the customer REST API, each handler loaded with the\n * first request for it. In the order the API root lists them: the people\n * first, then the records a team keeps about them, then a site's leads\n * (`/v1/leads?siteId=`, a top-level path with the site as a parameter), then\n * the letters sent from a record (AGL-2658).\n */\nconst CRM_API_V1_RESOURCES: ReadonlyArray<readonly [string, ApiV1ResourceHandler]> = [\n ['contacts', async (...args) => (await import('./server/api-v1/contacts')).handleContacts(...args)],\n ['companies', async (...args) => (await import('./server/api-v1/crm-companies')).handleCompanies(...args)],\n ['pipelines', async (...args) => (await import('./server/api-v1/crm-pipelines')).handlePipelines(...args)],\n ['deals', async (...args) => (await import('./server/api-v1/crm-deals')).handleDeals(...args)],\n ['tasks', async (...args) => (await import('./server/api-v1/crm-tasks')).handleTasks(...args)],\n ['activities', async (...args) => (await import('./server/api-v1/crm-activities')).handleActivities(...args)],\n ['leads', async (...args) => (await import('./server/api-v1/crm-leads')).handleLeads(...args)],\n [\n 'email-templates',\n async (...args) => (await import('./server/api-v1/crm-email-templates')).handleEmailTemplates(...args),\n ],\n]\n\n/**\n * The CRM's CONSOLE-ONLY server declarations, named under\n * `consoleServerDeclarations` in `plugins.config.json` and run at the\n * console's boot.\n *\n * It serves the CRM's resources of `/v1` (AGL-2606): the console's router\n * owns the pipeline in front of them — the key, the plan's API access, the\n * quota, the rate limit, the error envelope — and hands every request under\n * `/v1/contacts`, `/v1/deals` and the rest to the CRM once the key is\n * authenticated and the organization's plan carries the CRM. Each resource\n * brings its description for the API's OpenAPI document, and the CRM's\n * records band and collection sizes join `GET /v1/usage`. The API is the\n * console's alone, so the tenant runtime does not register them.\n *\n * It registers the server half of the CRM's import and export resources\n * (`transfer/register.ts`).\n *\n * It also clears a removed container off the CRM's records: a campaign is\n * deleted from the console, and its owner asks every plugin's membership\n * detacher first (`server/container-detach.ts`).\n *\n * Light at boot: each resource's module is imported with its first request,\n * the descriptions when the document is first built, and the usage reader\n * with the first usage call.\n * Registering again replaces this plugin's own entries, so a second call (a\n * hot reload, a spec) is harmless.\n */\nexport function registerCrmConsoleServerDeclarations(): void {\n // Email templates in a workspace package (AGL-3535).\n registerPluginTransferResource(\n CRM_EMAIL_TEMPLATES_TRANSFER_KEY,\n {\n items: async (ctx) => (await templatesPackage()).items(ctx),\n dependencies: (item) => crmEmailTemplateDependencies(item as CrmEmailTemplatePackageContent),\n remapIds: (item, idMap) => remapCrmEmailTemplateIds(item as CrmEmailTemplatePackageContent, idMap),\n readItems: async (ctx, ids) => (await templatesPackage()).readItems(ctx, ids),\n writeItems: async (ctx, items, writer) => (await templatesPackage()).writeItems(ctx, items as never, writer),\n revertItems: async (ctx, steps) => (await templatesPackage()).revertItems(ctx, steps as never),\n problems: async (ctx, write) => (await templatesPackage()).problems(ctx, write as never),\n rules: CRM_EMAIL_TEMPLATE_PACKAGE_RULES,\n planGate: crmTransferPlanGate('email templates'),\n },\n { pluginId: BUNDLE_ID },\n )\n // The record system's share of a person erasure (AGL-2623, AGL-3080): it\n // names the person's contacts before anybody erases, and erases them, their\n // satellites and their lead after everybody else. Required, and loaded with\n // the first erasure.\n registerPluginPersonRecordsEraser(\n {\n locate: async (target) => (await import('./server/person-eraser')).crmPersonEraser().locate(target),\n erase: async (request) => (await import('./server/person-eraser')).crmPersonEraser().erase(request),\n },\n { pluginId: BUNDLE_ID },\n )\n for (const [resource, handle] of CRM_API_V1_RESOURCES) {\n registerApiV1Resource(\n resource,\n {\n handle,\n entitlement: CRM_SUITE,\n describe: async () =>\n (await import('./server/api-v1/openapi')).CRM_API_V1_DESCRIPTIONS[resource],\n },\n { pluginId: BUNDLE_ID },\n )\n }\n registerApiV1UsageFigures(\n async (ctx) => (await import('./server/api-v1/usage')).crmUsageFigures(ctx),\n { pluginId: BUNDLE_ID },\n )\n // The CRM's setup an AI build asks for (AGL-3746): a pipeline and a custom\n // field, saved by these writers under the CRM's rules, and the operations\n // the build plans for them. The console runs AI jobs (AGL-3026).\n registerPluginResourceDraftWriter(CRM_PIPELINE_SETUP_RESOURCE, lazyCrmPipelineSetupWriter, { pluginId: BUNDLE_ID })\n registerPluginResourceDraftWriter(CRM_FIELD_SETUP_RESOURCE, lazyCrmFieldSetupWriter, { pluginId: BUNDLE_ID })\n registerCrmSetupAiCapabilities()\n // What the CRM imports and exports (AGL-3527): each resource's module\n // loads with the first transfer that asks for it.\n registerCrmTransferResources(BUNDLE_ID)\n // A removed container — a deleted campaign — comes off every lead and every\n // contact facet naming it, before its owner removes it (AGL-3080).\n if (!listPluginMembershipDetachers().includes(BUNDLE_ID)) {\n registerPluginMembershipDetacher(\n async (request) => (await import('./server/container-detach')).crmContainerDetacher(request),\n { pluginId: BUNDLE_ID },\n )\n }\n}\n"],"names":["registerApiV1Resource","registerApiV1UsageFigures","listPluginMembershipDetachers","registerPluginMembershipDetacher","registerPluginPersonRecordsEraser","registerPluginTransferResource","registerPluginResourceDraftWriter","BUNDLE_ID","checkCrmFieldSetup","checkCrmPipelineSetup","CRM_FIELD_SETUP_RESOURCE","CRM_PIPELINE_SETUP_RESOURCE","registerCrmSetupAiCapabilities","crmTransferPlanGate","registerCrmTransferResources","CRM_EMAIL_TEMPLATE_PACKAGE_RULES","CRM_EMAIL_TEMPLATES_TRANSFER_KEY","crmEmailTemplateDependencies","remapCrmEmailTemplateIds","setupLoading","setupWriters","catch","error","lazySetupWriter","check","writer","content","refusal","context","read","write","request","lazyCrmPipelineSetupWriter","module","crmPipelineSetupWriter","lazyCrmFieldSetupWriter","crmFieldSetupWriter","templatesPackage","createCrmEmailTemplatesPackage","CRM_SUITE","feature","message","CRM_API_V1_RESOURCES","args","handleContacts","handleCompanies","handlePipelines","handleDeals","handleTasks","handleActivities","handleLeads","handleEmailTemplates","registerCrmConsoleServerDeclarations","items","ctx","dependencies","item","remapIds","idMap","readItems","ids","writeItems","revertItems","steps","problems","rules","planGate","pluginId","locate","target","crmPersonEraser","erase","resource","handle","entitlement","describe","CRM_API_V1_DESCRIPTIONS","crmUsageFigures","includes","crmContainerDetacher"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,0CAA0C;AAC1C,SAEEA,qBAAqB,EACrBC,yBAAyB,QACpB,mDAAkD;AACzD,SACEC,6BAA6B,EAC7BC,gCAAgC,QAC3B,uDAAsD;AAC7D,SAASC,iCAAiC,QAAQ,oDAAmD;AACrG,SAASC,8BAA8B,QAAQ,wDAAuD;AACtG,SACEC,iCAAiC,QAE5B,qDAAoD;AAC3D,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SACEC,kBAAkB,EAClBC,qBAAqB,EACrBC,wBAAwB,EACxBC,2BAA2B,QACtB,+BAA2B;AAClC,SAASC,8BAA8B,QAAQ,wCAAoC;AACnF,SAASC,mBAAmB,EAAEC,4BAA4B,QAAQ,yBAAqB;AACvF,SACEC,gCAAgC,EAChCC,gCAAgC,EAChCC,4BAA4B,EAC5BC,wBAAwB,QAEnB,wCAAoC;AAG3C,IAAIC,eAAgD;AAEpD,gGAAgG,GAChG,SAASC;IACPD,uBAAAA,eAAAA,eAAiB,MAAM,CAAC,wCAAqCE,KAAK,CAAC,CAACC;QAClEH,eAAe;QACf,MAAMG;IACR;IACA,OAAOH;AACT;AAEA;;;CAGC,GACD,SAASI,gBACPC,KAAqG,EACrGC,MAA8D;IAE9D,OAAO;QACLD,OAAO,CAACE,UAAYF,MAAME;QAC1BC,SAAS,OAAOC,UAAYH,OAAO,MAAML,gBAAgBO,OAAO,CAACC;QACjEC,MAAM,OAAOD,UAAYH,OAAO,MAAML,gBAAgBS,IAAI,CAACD;QAC3DE,OAAO,OAAOC,UAAYN,OAAO,MAAML,gBAAgBU,KAAK,CAACC;IAC/D;AACF;AAEA,oDAAoD,GACpD,OAAO,MAAMC,6BAA6BT,gBACxCd,uBACA,CAACwB,SAAWA,OAAOC,sBAAsB,EAC1C;AAED,iDAAiD,GACjD,OAAO,MAAMC,0BAA0BZ,gBACrCf,oBACA,CAACyB,SAAWA,OAAOG,mBAAmB,EACvC;AAED,sGAAsG,GACtG,MAAMC,mBAAmB,UACvB,AAAC,CAAA,MAAM,MAAM,CAAC,+CAA2C,EAAGC,8BAA8B;AAE5F;;;;;CAKC,GACD,MAAMC,YAAY;IAChBC,SAAS;IACTC,SACE,oEACA;AACJ;AAEA;;;;;;CAMC,GACD,MAAMC,uBAA+E;IACnF;QAAC;QAAY,OAAO,GAAGC,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,8BAA0B,EAAGC,cAAc,IAAID;KAAM;IACnG;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGE,eAAe,IAAIF;KAAM;IAC1G;QAAC;QAAa,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,mCAA+B,EAAGG,eAAe,IAAIH;KAAM;IAC1G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGI,WAAW,IAAIJ;KAAM;IAC9F;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGK,WAAW,IAAIL;KAAM;IAC9F;QAAC;QAAc,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,oCAAgC,EAAGM,gBAAgB,IAAIN;KAAM;IAC7G;QAAC;QAAS,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGO,WAAW,IAAIP;KAAM;IAC9F;QACE;QACA,OAAO,GAAGA,OAAS,AAAC,CAAA,MAAM,MAAM,CAAC,yCAAqC,EAAGQ,oBAAoB,IAAIR;KAClG;CACF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,SAASS;IACd,qDAAqD;IACrD/C,+BACEW,kCACA;QACEqC,OAAO,OAAOC,MAAQ,AAAC,CAAA,MAAMjB,kBAAiB,EAAGgB,KAAK,CAACC;QACvDC,cAAc,CAACC,OAASvC,6BAA6BuC;QACrDC,UAAU,CAACD,MAAME,QAAUxC,yBAAyBsC,MAAwCE;QAC5FC,WAAW,OAAOL,KAAKM,MAAQ,AAAC,CAAA,MAAMvB,kBAAiB,EAAGsB,SAAS,CAACL,KAAKM;QACzEC,YAAY,OAAOP,KAAKD,OAAO5B,SAAW,AAAC,CAAA,MAAMY,kBAAiB,EAAGwB,UAAU,CAACP,KAAKD,OAAgB5B;QACrGqC,aAAa,OAAOR,KAAKS,QAAU,AAAC,CAAA,MAAM1B,kBAAiB,EAAGyB,WAAW,CAACR,KAAKS;QAC/EC,UAAU,OAAOV,KAAKxB,QAAU,AAAC,CAAA,MAAMO,kBAAiB,EAAG2B,QAAQ,CAACV,KAAKxB;QACzEmC,OAAOlD;QACPmD,UAAUrD,oBAAoB;IAChC,GACA;QAAEsD,UAAU5D;IAAU;IAExB,yEAAyE;IACzE,4EAA4E;IAC5E,4EAA4E;IAC5E,qBAAqB;IACrBH,kCACE;QACEgE,QAAQ,OAAOC,SAAW,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGC,eAAe,GAAGF,MAAM,CAACC;QAC5FE,OAAO,OAAOxC,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,4BAAwB,EAAGuC,eAAe,GAAGC,KAAK,CAACxC;IAC7F,GACA;QAAEoC,UAAU5D;IAAU;IAExB,KAAK,MAAM,CAACiE,UAAUC,OAAO,IAAI/B,qBAAsB;QACrD1C,sBACEwE,UACA;YACEC;YACAC,aAAanC;YACboC,UAAU,UACR,AAAC,CAAA,MAAM,MAAM,CAAC,6BAAyB,EAAGC,uBAAuB,CAACJ,SAAS;QAC/E,GACA;YAAEL,UAAU5D;QAAU;IAE1B;IACAN,0BACE,OAAOqD,MAAQ,AAAC,CAAA,MAAM,MAAM,CAAC,2BAAuB,EAAGuB,eAAe,CAACvB,MACvE;QAAEa,UAAU5D;IAAU;IAExB,2EAA2E;IAC3E,0EAA0E;IAC1E,iEAAiE;IACjED,kCAAkCK,6BAA6BqB,4BAA4B;QAAEmC,UAAU5D;IAAU;IACjHD,kCAAkCI,0BAA0ByB,yBAAyB;QAAEgC,UAAU5D;IAAU;IAC3GK;IACA,sEAAsE;IACtE,kDAAkD;IAClDE,6BAA6BP;IAC7B,4EAA4E;IAC5E,mEAAmE;IACnE,IAAI,CAACL,gCAAgC4E,QAAQ,CAACvE,YAAY;QACxDJ,iCACE,OAAO4B,UAAY,AAAC,CAAA,MAAM,MAAM,CAAC,+BAA2B,EAAGgD,oBAAoB,CAAChD,UACpF;YAAEoC,UAAU5D;QAAU;IAE1B;AACF"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { registerPluginRecordRoute } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
|
|
15
15
|
import { BUNDLE_ID } from "../constants/bundle-common.js";
|
|
16
16
|
import { crmRoutes } from "./crm-routes.js";
|
|
17
|
+
import { CRM_FIELD_SETUP_RESOURCE, CRM_PIPELINE_SETUP_RESOURCE } from "./crm-setup-content.js";
|
|
17
18
|
/**
|
|
18
19
|
* Where the CRM's records are read, published for every other surface.
|
|
19
20
|
*
|
|
@@ -66,6 +67,17 @@ function hub(context) {
|
|
|
66
67
|
list: (context)=>hub(context).section('deals'),
|
|
67
68
|
record: (context, id)=>hub(context).deal(id)
|
|
68
69
|
}, owner);
|
|
70
|
+
// The setup an AI build saves (AGL-3746): a pipeline is edited from the
|
|
71
|
+
// Deals section's Pipelines, and a custom field is a row in Fields, so
|
|
72
|
+
// each record answers its section.
|
|
73
|
+
registerPluginRecordRoute(CRM_PIPELINE_SETUP_RESOURCE, {
|
|
74
|
+
list: (context)=>hub(context).section('deals'),
|
|
75
|
+
record: (context)=>hub(context).section('deals')
|
|
76
|
+
}, owner);
|
|
77
|
+
registerPluginRecordRoute(CRM_FIELD_SETUP_RESOURCE, {
|
|
78
|
+
list: (context)=>hub(context).section('fields'),
|
|
79
|
+
record: (context)=>hub(context).section('fields')
|
|
80
|
+
}, owner);
|
|
69
81
|
// A task has no page of its own: it is a row in the Tasks list, at both
|
|
70
82
|
// levels, so a task's record answers the list.
|
|
71
83
|
registerPluginRecordRoute('task', {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-record-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\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 * http://www.apache.org/licenses/LICENSE-2.0\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\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { crmRoutes, type CrmRoutes } from './crm-routes'\n\n/**\n * Where the CRM's records are read, published for every other surface.\n *\n * The Inbox links a lead, a submission links its sender, a form links the\n * people it captured. Each used to import {@link crmRoutes} and spell the\n * CRM's nav slug to do it, which made every one of them a reader of this\n * plugin's address table. They ask the record-route registry now, and get\n * `null` — text instead of a link — in a workspace where the CRM is not\n * loaded, which is also the first time that link stopped pointing at a page\n * the workspace cannot open.\n */\n\n/** The nav slug the shell resolves this plugin's hub by, at either scope. */\nconst CRM_SLUG = 'crm'\n\nfunction hub(context: PluginRecordRouteContext): CrmRoutes {\n return crmRoutes(\n context.host\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug: context.orgSlug,\n host: context.host,\n pluginSlug: CRM_SLUG,\n })\n : buildRoute(Route.ORG_PLUGIN, {\n orgSlug: context.orgSlug,\n pluginSlug: CRM_SLUG,\n }),\n )\n}\n\n/** The hub's routes at the scope the context names — the site's, or the org's. */\nexport { hub as crmHubRoutes }\n\n/** The one narrowing the contacts list publishes: the form that captured them. */\nexport const CRM_CONTACT_FILTER_FORM = 'form'\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the routes register the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerCrmRecordRoutes(): void {\n const owner = { pluginId: BUNDLE_ID }\n registerPluginRecordRoute('contact', {\n list: (context) => hub(context).section('contacts'),\n record: (context, id) => hub(context).contact(id),\n byEmail: (context, email) => hub(context).contactByEmail(email),\n filtered: (context, filter, value) =>\n filter === CRM_CONTACT_FILTER_FORM\n ? hub(context).contactsByForm(value)\n : null,\n }, owner)\n registerPluginRecordRoute('lead', {\n list: (context) => hub(context).section('leads'),\n // One org row per person since AGL-3275, so the id alone addresses a\n // lead at both levels — the organization's hub included, where the\n // organization's Inbox links its leads (AGL-3303).\n record: (context, id) => hub(context).lead(id),\n }, owner)\n registerPluginRecordRoute('company', {\n list: (context) => hub(context).section('companies'),\n record: (context, id) => hub(context).company(id),\n }, owner)\n registerPluginRecordRoute('deal', {\n list: (context) => hub(context).section('deals'),\n record: (context, id) => hub(context).deal(id),\n }, owner)\n // A task has no page of its own: it is a row in the Tasks list, at both\n // levels, so a task's record answers the list.\n registerPluginRecordRoute('task', {\n list: (context) => hub(context).section('tasks'),\n record: (context) => hub(context).section('tasks'),\n }, owner)\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","crmRoutes","CRM_SLUG","hub","context","host","HOST_PLUGIN","orgSlug","pluginSlug","ORG_PLUGIN","crmHubRoutes","CRM_CONTACT_FILTER_FORM","registerCrmRecordRoutes","owner","pluginId","list","section","record","id","contact","byEmail","email","contactByEmail","filtered","filter","value","contactsByForm","lead","company","deal"],"mappings":"AAAA;;;;;;;;;;;;CAYC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,SAAS,QAAwB,kBAAc;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-record-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\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 * http://www.apache.org/licenses/LICENSE-2.0\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\nimport { buildRoute, Route } from '@aglyn/aglyn/app-utils/console-routes'\nimport {\n registerPluginRecordRoute,\n type PluginRecordRouteContext,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-routes'\nimport { BUNDLE_ID } from '../constants/bundle-common'\nimport { crmRoutes, type CrmRoutes } from './crm-routes'\nimport { CRM_FIELD_SETUP_RESOURCE, CRM_PIPELINE_SETUP_RESOURCE } from './crm-setup-content'\n\n/**\n * Where the CRM's records are read, published for every other surface.\n *\n * The Inbox links a lead, a submission links its sender, a form links the\n * people it captured. Each used to import {@link crmRoutes} and spell the\n * CRM's nav slug to do it, which made every one of them a reader of this\n * plugin's address table. They ask the record-route registry now, and get\n * `null` — text instead of a link — in a workspace where the CRM is not\n * loaded, which is also the first time that link stopped pointing at a page\n * the workspace cannot open.\n */\n\n/** The nav slug the shell resolves this plugin's hub by, at either scope. */\nconst CRM_SLUG = 'crm'\n\nfunction hub(context: PluginRecordRouteContext): CrmRoutes {\n return crmRoutes(\n context.host\n ? buildRoute(Route.HOST_PLUGIN, {\n orgSlug: context.orgSlug,\n host: context.host,\n pluginSlug: CRM_SLUG,\n })\n : buildRoute(Route.ORG_PLUGIN, {\n orgSlug: context.orgSlug,\n pluginSlug: CRM_SLUG,\n }),\n )\n}\n\n/** The hub's routes at the scope the context names — the site's, or the org's. */\nexport { hub as crmHubRoutes }\n\n/** The one narrowing the contacts list publishes: the form that captured them. */\nexport const CRM_CONTACT_FILTER_FORM = 'form'\n\n/**\n * Called from the console registrar. The owner is named rather than read off\n * the loader's marker, so the routes register the same way under a spec that\n * calls the registrar directly.\n */\nexport function registerCrmRecordRoutes(): void {\n const owner = { pluginId: BUNDLE_ID }\n registerPluginRecordRoute('contact', {\n list: (context) => hub(context).section('contacts'),\n record: (context, id) => hub(context).contact(id),\n byEmail: (context, email) => hub(context).contactByEmail(email),\n filtered: (context, filter, value) =>\n filter === CRM_CONTACT_FILTER_FORM\n ? hub(context).contactsByForm(value)\n : null,\n }, owner)\n registerPluginRecordRoute('lead', {\n list: (context) => hub(context).section('leads'),\n // One org row per person since AGL-3275, so the id alone addresses a\n // lead at both levels — the organization's hub included, where the\n // organization's Inbox links its leads (AGL-3303).\n record: (context, id) => hub(context).lead(id),\n }, owner)\n registerPluginRecordRoute('company', {\n list: (context) => hub(context).section('companies'),\n record: (context, id) => hub(context).company(id),\n }, owner)\n registerPluginRecordRoute('deal', {\n list: (context) => hub(context).section('deals'),\n record: (context, id) => hub(context).deal(id),\n }, owner)\n // The setup an AI build saves (AGL-3746): a pipeline is edited from the\n // Deals section's Pipelines, and a custom field is a row in Fields, so\n // each record answers its section.\n registerPluginRecordRoute(CRM_PIPELINE_SETUP_RESOURCE, {\n list: (context) => hub(context).section('deals'),\n record: (context) => hub(context).section('deals'),\n }, owner)\n registerPluginRecordRoute(CRM_FIELD_SETUP_RESOURCE, {\n list: (context) => hub(context).section('fields'),\n record: (context) => hub(context).section('fields'),\n }, owner)\n // A task has no page of its own: it is a row in the Tasks list, at both\n // levels, so a task's record answers the list.\n registerPluginRecordRoute('task', {\n list: (context) => hub(context).section('tasks'),\n record: (context) => hub(context).section('tasks'),\n }, owner)\n}\n"],"names":["buildRoute","Route","registerPluginRecordRoute","BUNDLE_ID","crmRoutes","CRM_FIELD_SETUP_RESOURCE","CRM_PIPELINE_SETUP_RESOURCE","CRM_SLUG","hub","context","host","HOST_PLUGIN","orgSlug","pluginSlug","ORG_PLUGIN","crmHubRoutes","CRM_CONTACT_FILTER_FORM","registerCrmRecordRoutes","owner","pluginId","list","section","record","id","contact","byEmail","email","contactByEmail","filtered","filter","value","contactsByForm","lead","company","deal"],"mappings":"AAAA;;;;;;;;;;;;CAYC,GAED,SAASA,UAAU,EAAEC,KAAK,QAAQ,wCAAuC;AACzE,SACEC,yBAAyB,QAEpB,mDAAkD;AACzD,SAASC,SAAS,QAAQ,gCAA4B;AACtD,SAASC,SAAS,QAAwB,kBAAc;AACxD,SAASC,wBAAwB,EAAEC,2BAA2B,QAAQ,yBAAqB;AAE3F;;;;;;;;;;CAUC,GAED,2EAA2E,GAC3E,MAAMC,WAAW;AAEjB,SAASC,IAAIC,OAAiC;IAC5C,OAAOL,UACLK,QAAQC,IAAI,GACRV,WAAWC,MAAMU,WAAW,EAAE;QAC5BC,SAASH,QAAQG,OAAO;QACxBF,MAAMD,QAAQC,IAAI;QAClBG,YAAYN;IACd,KACAP,WAAWC,MAAMa,UAAU,EAAE;QAC3BF,SAASH,QAAQG,OAAO;QACxBC,YAAYN;IACd;AAER;AAEA,gFAAgF,GAChF,SAASC,OAAOO,YAAY,GAAE;AAE9B,gFAAgF,GAChF,OAAO,MAAMC,0BAA0B,OAAM;AAE7C;;;;CAIC,GACD,OAAO,SAASC;IACd,MAAMC,QAAQ;QAAEC,UAAUhB;IAAU;IACpCD,0BAA0B,WAAW;QACnCkB,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,SAASc,KAAOf,IAAIC,SAASe,OAAO,CAACD;QAC9CE,SAAS,CAAChB,SAASiB,QAAUlB,IAAIC,SAASkB,cAAc,CAACD;QACzDE,UAAU,CAACnB,SAASoB,QAAQC,QAC1BD,WAAWb,0BACPR,IAAIC,SAASsB,cAAc,CAACD,SAC5B;IACR,GAAGZ;IACHhB,0BAA0B,QAAQ;QAChCkB,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxC,qEAAqE;QACrE,mEAAmE;QACnE,mDAAmD;QACnDC,QAAQ,CAACb,SAASc,KAAOf,IAAIC,SAASuB,IAAI,CAACT;IAC7C,GAAGL;IACHhB,0BAA0B,WAAW;QACnCkB,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,SAASc,KAAOf,IAAIC,SAASwB,OAAO,CAACV;IAChD,GAAGL;IACHhB,0BAA0B,QAAQ;QAChCkB,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,SAASc,KAAOf,IAAIC,SAASyB,IAAI,CAACX;IAC7C,GAAGL;IACH,wEAAwE;IACxE,uEAAuE;IACvE,mCAAmC;IACnChB,0BAA0BI,6BAA6B;QACrDc,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,UAAYD,IAAIC,SAASY,OAAO,CAAC;IAC5C,GAAGH;IACHhB,0BAA0BG,0BAA0B;QAClDe,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,UAAYD,IAAIC,SAASY,OAAO,CAAC;IAC5C,GAAGH;IACH,wEAAwE;IACxE,+CAA+C;IAC/ChB,0BAA0B,QAAQ;QAChCkB,MAAM,CAACX,UAAYD,IAAIC,SAASY,OAAO,CAAC;QACxCC,QAAQ,CAACb,UAAYD,IAAIC,SAASY,OAAO,CAAC;IAC5C,GAAGH;AACL"}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type ContactFieldDefinition, type ContactFieldType, type CrmDealStage, type CrmFieldObject } from '@aglyn/aglyn/app-utils/crm';
|
|
18
|
+
import type { PluginDraftCheck } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts';
|
|
19
|
+
import { type PipelineDoc, type PipelineWriteContext } from './deal-board-model';
|
|
20
|
+
/**
|
|
21
|
+
* CRM SETUP ANOTHER PLUGIN ASKS FOR (AGL-3746): a deal pipeline with its
|
|
22
|
+
* stages, and a custom field on a CRM object — what a person sets up in
|
|
23
|
+
* CRM → Deals → Pipelines and CRM → Fields, as pure rules.
|
|
24
|
+
*
|
|
25
|
+
* Aglyn AI's build asks for them through the CRM's writers on the
|
|
26
|
+
* resource-drafts seam (`server/crm-setup-writers.server.ts`). Neither has a
|
|
27
|
+
* draft state: a pipeline and a field are in use once saved, as the console's
|
|
28
|
+
* own saves are. So every rule here is the console's, read from the modules
|
|
29
|
+
* the console reads:
|
|
30
|
+
*
|
|
31
|
+
* - A PIPELINE is `newPipelineDocument` with the stages asked for in place
|
|
32
|
+
* of the default set, held to `stagesProblem` (exactly one Won and one
|
|
33
|
+
* Lost, at least one open stage) and named under `pipelineNameProblem`
|
|
34
|
+
* (unique among the active pipelines, without case). Stage ids are
|
|
35
|
+
* `newStageId`'s slugs.
|
|
36
|
+
* - A FIELD is the Fields section's create: a key derived from the label by
|
|
37
|
+
* `normalizeContactFieldKey`, one of `CONTACT_FIELD_TYPES`, the choices
|
|
38
|
+
* the drawer allows, landing last on its object's tab, org-wide.
|
|
39
|
+
*
|
|
40
|
+
* IDEMPOTENT BY NAME. Asked twice, nothing is made twice: an active pipeline
|
|
41
|
+
* is matched by name and a stage within it by name, a field by label or key
|
|
42
|
+
* within its object, all without case. A match is brought to what was
|
|
43
|
+
* asked — missing stages added, stages put in the asked order, a
|
|
44
|
+
* probability set — and nothing is ever removed: a stage may hold deals and
|
|
45
|
+
* a field values, and removing either is a person's decision.
|
|
46
|
+
*/
|
|
47
|
+
/** The resource the CRM's pipeline writer is registered under. */
|
|
48
|
+
export declare const CRM_PIPELINE_SETUP_RESOURCE = "crm-pipeline";
|
|
49
|
+
/** The longest stage name, as long as a pipeline's name. */
|
|
50
|
+
export declare const CRM_SETUP_STAGE_NAME_MAX = 60;
|
|
51
|
+
/** The most open stages one ask sets; a board wider than this does not read. */
|
|
52
|
+
export declare const CRM_SETUP_OPEN_STAGES_MAX = 12;
|
|
53
|
+
/** A pipeline as one ask states it, once read. */
|
|
54
|
+
export interface CrmPipelineSetup {
|
|
55
|
+
name: string;
|
|
56
|
+
/** An active pipeline to rename to `name`, by its current name. */
|
|
57
|
+
renameFrom: string | null;
|
|
58
|
+
/** The open stages, in order, each with the probability asked for, if any. */
|
|
59
|
+
stages: Array<{
|
|
60
|
+
name: string;
|
|
61
|
+
probability: number | null;
|
|
62
|
+
}>;
|
|
63
|
+
/** What the Won and Lost stages are called, where the ask says. */
|
|
64
|
+
wonStage: string | null;
|
|
65
|
+
lostStage: string | null;
|
|
66
|
+
/** Whether this becomes the pipeline a new deal lands in. */
|
|
67
|
+
makeDefault: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** One listed stage, read: its name and the probability written after it. */
|
|
70
|
+
export declare function readCrmSetupStage(entry: string): {
|
|
71
|
+
name: string;
|
|
72
|
+
probability: number | null;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A pipeline's content, read and judged. Pure. A stage listed as Won or
|
|
76
|
+
* Lost ("Won", "Closed Lost") names the closing stage rather than adding an
|
|
77
|
+
* open one, since every pipeline already has exactly one of each.
|
|
78
|
+
*/
|
|
79
|
+
export declare function readCrmPipelineSetup(content: Readonly<Record<string, unknown>>): {
|
|
80
|
+
ok: true;
|
|
81
|
+
value: CrmPipelineSetup;
|
|
82
|
+
} | {
|
|
83
|
+
ok: false;
|
|
84
|
+
problems: string[];
|
|
85
|
+
};
|
|
86
|
+
/** The `crm-pipeline` writer's check: whether the content is a pipeline ask, with its name and stages. Pure. */
|
|
87
|
+
export declare function checkCrmPipelineSetup(content: Readonly<Record<string, unknown>>): PluginDraftCheck;
|
|
88
|
+
/**
|
|
89
|
+
* The probability an open stage takes when the ask names none: spread over
|
|
90
|
+
* the pipeline by position, to the nearest 5 — the first of four stages 20,
|
|
91
|
+
* the last 80 — so a weighted forecast grows as a deal moves right.
|
|
92
|
+
*/
|
|
93
|
+
export declare function crmSetupStageProbability(index: number, count: number): number;
|
|
94
|
+
/**
|
|
95
|
+
* The stages a pipeline ends up with: the open stages asked for, in the asked
|
|
96
|
+
* order, each an existing stage of that name where there is one (its id kept,
|
|
97
|
+
* so the deals in it stay), then the open stages the ask did not name, as
|
|
98
|
+
* they were, then Won and Lost, renamed where asked. Nothing is removed.
|
|
99
|
+
*/
|
|
100
|
+
export declare function mergeCrmSetupStages(existing: readonly CrmDealStage[], setup: Pick<CrmPipelineSetup, 'stages' | 'wonStage' | 'lostStage'>): CrmDealStage[];
|
|
101
|
+
/** What saving a pipeline ask comes to against the pipelines the member's site can read. */
|
|
102
|
+
export type CrmPipelineSetupPlan = {
|
|
103
|
+
kind: 'create';
|
|
104
|
+
document: Record<string, unknown>;
|
|
105
|
+
/** Pipelines whose default flag comes off, because this one takes it. */
|
|
106
|
+
defaultsOff: string[];
|
|
107
|
+
} | {
|
|
108
|
+
kind: 'update';
|
|
109
|
+
id: string;
|
|
110
|
+
name: string;
|
|
111
|
+
/** The fields that change; empty when the pipeline already is what was asked. */
|
|
112
|
+
patch: {
|
|
113
|
+
name?: string;
|
|
114
|
+
stages?: CrmDealStage[];
|
|
115
|
+
isDefault?: true;
|
|
116
|
+
};
|
|
117
|
+
defaultsOff: string[];
|
|
118
|
+
} | {
|
|
119
|
+
kind: 'refuse';
|
|
120
|
+
error: string;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Create, or bring an existing pipeline to the ask. Pure.
|
|
124
|
+
*
|
|
125
|
+
* The pipeline is the active one called `renameFrom`, else the active one
|
|
126
|
+
* called `name`; with neither, a new one, which is also the default when the
|
|
127
|
+
* site reads no active pipeline yet (the board's seed would have made it so)
|
|
128
|
+
* or when asked.
|
|
129
|
+
*/
|
|
130
|
+
export declare function planCrmPipelineSetup(setup: CrmPipelineSetup, pipelines: readonly PipelineDoc[], context: PipelineWriteContext): CrmPipelineSetupPlan;
|
|
131
|
+
/** What a pipeline's writer reports: its open stages in order, and its closing stages. */
|
|
132
|
+
export declare function crmPipelineSetupFacts(stages: readonly CrmDealStage[]): Readonly<Record<string, unknown>>;
|
|
133
|
+
/** The resource the CRM's custom field writer is registered under. */
|
|
134
|
+
export declare const CRM_FIELD_SETUP_RESOURCE = "crm-field";
|
|
135
|
+
/** How many choices a Choice field may declare, and how long each and a label may be: the field drawer's. */
|
|
136
|
+
export declare const CRM_FIELD_OPTIONS_MAX = 50;
|
|
137
|
+
export declare const CRM_FIELD_OPTION_MAX_LENGTH = 120;
|
|
138
|
+
export declare const CRM_FIELD_LABEL_MAX_LENGTH = 80;
|
|
139
|
+
/** One choice per entry, trimmed, blanks dropped, duplicates collapsed, at most the drawer's count. */
|
|
140
|
+
export declare function crmFieldOptions(entries: readonly string[]): string[];
|
|
141
|
+
/** A custom field as one ask states it, once read. */
|
|
142
|
+
export interface CrmFieldSetup {
|
|
143
|
+
object: CrmFieldObject;
|
|
144
|
+
label: string;
|
|
145
|
+
/** The key its values are stored under, derived from the label. */
|
|
146
|
+
key: string;
|
|
147
|
+
type: ContactFieldType;
|
|
148
|
+
/** The choices, for `select`; empty otherwise. */
|
|
149
|
+
options: string[];
|
|
150
|
+
required: boolean;
|
|
151
|
+
}
|
|
152
|
+
/** A field's content, read and judged. Pure. */
|
|
153
|
+
export declare function readCrmFieldSetup(content: Readonly<Record<string, unknown>>): {
|
|
154
|
+
ok: true;
|
|
155
|
+
value: CrmFieldSetup;
|
|
156
|
+
} | {
|
|
157
|
+
ok: false;
|
|
158
|
+
problems: string[];
|
|
159
|
+
};
|
|
160
|
+
/** The `crm-field` writer's check: whether the content is a field ask, with the key it derives. Pure. */
|
|
161
|
+
export declare function checkCrmFieldSetup(content: Readonly<Record<string, unknown>>): PluginDraftCheck;
|
|
162
|
+
/** A stored definition, as the writer reads one: its document id beside it. */
|
|
163
|
+
export type CrmFieldDefinitionDoc = ContactFieldDefinition & {
|
|
164
|
+
$id: string;
|
|
165
|
+
};
|
|
166
|
+
/** What saving a field ask comes to against every definition the org holds. */
|
|
167
|
+
export type CrmFieldSetupPlan = {
|
|
168
|
+
kind: 'create';
|
|
169
|
+
document: Record<string, unknown>;
|
|
170
|
+
}
|
|
171
|
+
/** The field exists; `options` are the choices its Choice list gains, empty when none. */
|
|
172
|
+
| {
|
|
173
|
+
kind: 'existing';
|
|
174
|
+
id: string;
|
|
175
|
+
label: string;
|
|
176
|
+
options: string[];
|
|
177
|
+
} | {
|
|
178
|
+
kind: 'refuse';
|
|
179
|
+
status: 403 | 409;
|
|
180
|
+
error: string;
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* Create the field, or find it. Pure.
|
|
184
|
+
*
|
|
185
|
+
* An active field on the same object with the same label or key is the
|
|
186
|
+
* field: a re-run makes nothing, and a Choice field gains only the choices it
|
|
187
|
+
* lacks. A retired field keeps its key, as the drawer says, so a key a
|
|
188
|
+
* retired field holds is refused rather than reused — its old values would
|
|
189
|
+
* read back as the new field's. The org's ceiling counts every definition,
|
|
190
|
+
* retired ones included, as the Fields section's read does.
|
|
191
|
+
*/
|
|
192
|
+
export declare function planCrmFieldSetup(setup: CrmFieldSetup, definitions: readonly CrmFieldDefinitionDoc[], context: {
|
|
193
|
+
hostId: string;
|
|
194
|
+
now: Date;
|
|
195
|
+
visibleTo: readonly string[];
|
|
196
|
+
}): CrmFieldSetupPlan;
|