@aglyn/plugins-crm 1.0.0-beta.243 → 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.
@@ -0,0 +1,416 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ // Modules by their own paths, never the `@aglyn/aglyn` barrel: the console
18
+ // server's boot reaches this file through the CRM's setup writers.
19
+ import { CONTACT_FIELD_TYPES, CONTACT_FIELDS_MAX_PER_ORG, fieldDefinitionsForObject, isContactFieldType } from "@aglyn/aglyn/app-utils/contact-custom-fields";
20
+ import { CRM_FIELD_OBJECT_LABELS, CRM_FIELD_OBJECTS, crmFieldListFields, DEFAULT_DEAL_STAGES, isCrmFieldObject, normalizeContactFieldKey } from "@aglyn/aglyn/app-utils/crm";
21
+ import { activePipelines, clampProbability, newPipelineDocument, newStageId, PIPELINE_NAME_MAX, pipelineNameProblem, renumberStages, sortedStages, stagesProblem } from "./deal-board-model.js";
22
+ /**
23
+ * CRM SETUP ANOTHER PLUGIN ASKS FOR (AGL-3746): a deal pipeline with its
24
+ * stages, and a custom field on a CRM object — what a person sets up in
25
+ * CRM → Deals → Pipelines and CRM → Fields, as pure rules.
26
+ *
27
+ * Aglyn AI's build asks for them through the CRM's writers on the
28
+ * resource-drafts seam (`server/crm-setup-writers.server.ts`). Neither has a
29
+ * draft state: a pipeline and a field are in use once saved, as the console's
30
+ * own saves are. So every rule here is the console's, read from the modules
31
+ * the console reads:
32
+ *
33
+ * - A PIPELINE is `newPipelineDocument` with the stages asked for in place
34
+ * of the default set, held to `stagesProblem` (exactly one Won and one
35
+ * Lost, at least one open stage) and named under `pipelineNameProblem`
36
+ * (unique among the active pipelines, without case). Stage ids are
37
+ * `newStageId`'s slugs.
38
+ * - A FIELD is the Fields section's create: a key derived from the label by
39
+ * `normalizeContactFieldKey`, one of `CONTACT_FIELD_TYPES`, the choices
40
+ * the drawer allows, landing last on its object's tab, org-wide.
41
+ *
42
+ * IDEMPOTENT BY NAME. Asked twice, nothing is made twice: an active pipeline
43
+ * is matched by name and a stage within it by name, a field by label or key
44
+ * within its object, all without case. A match is brought to what was
45
+ * asked — missing stages added, stages put in the asked order, a
46
+ * probability set — and nothing is ever removed: a stage may hold deals and
47
+ * a field values, and removing either is a person's decision.
48
+ */ // ── Pipelines ─────────────────────────────────────────────────────────────
49
+ /** The resource the CRM's pipeline writer is registered under. */ export const CRM_PIPELINE_SETUP_RESOURCE = 'crm-pipeline';
50
+ /** The longest stage name, as long as a pipeline's name. */ export const CRM_SETUP_STAGE_NAME_MAX = PIPELINE_NAME_MAX;
51
+ /** The most open stages one ask sets; a board wider than this does not read. */ export const CRM_SETUP_OPEN_STAGES_MAX = 12;
52
+ const WON_NAME = /^(closed[\s-]*)?won$/i;
53
+ const LOST_NAME = /^(closed[\s-]*)?lost$/i;
54
+ /** "Discovery (20%)": a stage's name with its probability. */ const STAGE_WITH_PROBABILITY = /^(.*?)\s*\(\s*(\d{1,3}(?:\.\d+)?)\s*%?\s*\)\s*$/;
55
+ function text(value) {
56
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
57
+ }
58
+ const same = (a, b)=>a.trim().toLowerCase() === b.trim().toLowerCase();
59
+ /** One listed stage, read: its name and the probability written after it. */ export function readCrmSetupStage(entry) {
60
+ const raw = text(entry);
61
+ const match = STAGE_WITH_PROBABILITY.exec(raw);
62
+ if (!match) return {
63
+ name: raw,
64
+ probability: null
65
+ };
66
+ return {
67
+ name: text(match[1]),
68
+ probability: clampProbability(match[2])
69
+ };
70
+ }
71
+ /**
72
+ * A pipeline's content, read and judged. Pure. A stage listed as Won or
73
+ * Lost ("Won", "Closed Lost") names the closing stage rather than adding an
74
+ * open one, since every pipeline already has exactly one of each.
75
+ */ export function readCrmPipelineSetup(content) {
76
+ const problems = [];
77
+ const name = text(content['name']);
78
+ if (!name) problems.push('A pipeline needs a name.');
79
+ else if (name.length > PIPELINE_NAME_MAX) problems.push(`A pipeline's name is at most ${PIPELINE_NAME_MAX} characters.`);
80
+ let wonStage = text(content['wonStage']) || null;
81
+ let lostStage = text(content['lostStage']) || null;
82
+ const listed = Array.isArray(content['stages']) ? content['stages'] : [];
83
+ const stages = [];
84
+ for (const entry of listed){
85
+ if (typeof entry !== 'string') {
86
+ problems.push('Each stage is a name.');
87
+ break;
88
+ }
89
+ const stage = readCrmSetupStage(entry);
90
+ if (!stage.name) continue;
91
+ if (WON_NAME.test(stage.name)) {
92
+ wonStage != null ? wonStage : wonStage = stage.name;
93
+ continue;
94
+ }
95
+ if (LOST_NAME.test(stage.name)) {
96
+ lostStage != null ? lostStage : lostStage = stage.name;
97
+ continue;
98
+ }
99
+ if (stage.name.length > CRM_SETUP_STAGE_NAME_MAX) {
100
+ problems.push(`A stage's name is at most ${CRM_SETUP_STAGE_NAME_MAX} characters.`);
101
+ break;
102
+ }
103
+ if (stages.some((one)=>same(one.name, stage.name))) continue;
104
+ stages.push(stage);
105
+ }
106
+ if (stages.length > CRM_SETUP_OPEN_STAGES_MAX) {
107
+ problems.push(`A pipeline is set up with at most ${CRM_SETUP_OPEN_STAGES_MAX} open stages.`);
108
+ }
109
+ for (const closing of [
110
+ wonStage,
111
+ lostStage
112
+ ]){
113
+ if (closing && closing.length > CRM_SETUP_STAGE_NAME_MAX) {
114
+ problems.push(`A stage's name is at most ${CRM_SETUP_STAGE_NAME_MAX} characters.`);
115
+ }
116
+ }
117
+ if (wonStage && lostStage && same(wonStage, lostStage)) problems.push('The Won and Lost stages need different names.');
118
+ if (problems.length) return {
119
+ ok: false,
120
+ problems
121
+ };
122
+ return {
123
+ ok: true,
124
+ value: {
125
+ name,
126
+ renameFrom: text(content['renameFrom']) || null,
127
+ stages,
128
+ wonStage,
129
+ lostStage,
130
+ makeDefault: content['makeDefault'] === true
131
+ }
132
+ };
133
+ }
134
+ /** The `crm-pipeline` writer's check: whether the content is a pipeline ask, with its name and stages. Pure. */ export function checkCrmPipelineSetup(content) {
135
+ const read = readCrmPipelineSetup(content);
136
+ if (read.ok === false) return {
137
+ ok: false,
138
+ problems: read.problems
139
+ };
140
+ return {
141
+ ok: true,
142
+ facts: {
143
+ name: read.value.name,
144
+ stages: read.value.stages.map((one)=>one.name)
145
+ }
146
+ };
147
+ }
148
+ /**
149
+ * The probability an open stage takes when the ask names none: spread over
150
+ * the pipeline by position, to the nearest 5 — the first of four stages 20,
151
+ * the last 80 — so a weighted forecast grows as a deal moves right.
152
+ */ export function crmSetupStageProbability(index, count) {
153
+ const spread = (index + 1) / (count + 1) * 100;
154
+ return Math.min(95, Math.max(5, Math.round(spread / 5) * 5));
155
+ }
156
+ /**
157
+ * The stages a pipeline ends up with: the open stages asked for, in the asked
158
+ * order, each an existing stage of that name where there is one (its id kept,
159
+ * so the deals in it stay), then the open stages the ask did not name, as
160
+ * they were, then Won and Lost, renamed where asked. Nothing is removed.
161
+ */ export function mergeCrmSetupStages(existing, setup) {
162
+ const sorted = sortedStages({
163
+ stages: [
164
+ ...existing
165
+ ]
166
+ });
167
+ const opens = sorted.filter((stage)=>stage.kind === 'open');
168
+ const taken = [
169
+ ...sorted
170
+ ];
171
+ const listed = setup.stages.map((asked, index)=>{
172
+ var _asked_probability;
173
+ const found = opens.find((stage)=>same(stage.name, asked.name));
174
+ if (found) return asked.probability === null ? found : _extends({}, found, {
175
+ probability: asked.probability
176
+ });
177
+ const stage = {
178
+ id: newStageId(asked.name, taken),
179
+ name: asked.name,
180
+ order: 0,
181
+ probability: (_asked_probability = asked.probability) != null ? _asked_probability : crmSetupStageProbability(index, setup.stages.length),
182
+ kind: 'open',
183
+ forecastCategory: 'pipeline'
184
+ };
185
+ taken.push(stage);
186
+ return stage;
187
+ });
188
+ const unlisted = opens.filter((stage)=>!listed.some((one)=>one.id === stage.id));
189
+ const closing = (kind, name)=>sorted.filter((stage)=>stage.kind === kind).map((stage)=>name ? _extends({}, stage, {
190
+ name
191
+ }) : stage);
192
+ return renumberStages([
193
+ ...listed,
194
+ ...unlisted,
195
+ ...closing('won', setup.wonStage),
196
+ ...closing('lost', setup.lostStage)
197
+ ]);
198
+ }
199
+ /**
200
+ * Create, or bring an existing pipeline to the ask. Pure.
201
+ *
202
+ * The pipeline is the active one called `renameFrom`, else the active one
203
+ * called `name`; with neither, a new one, which is also the default when the
204
+ * site reads no active pipeline yet (the board's seed would have made it so)
205
+ * or when asked.
206
+ */ export function planCrmPipelineSetup(setup, pipelines, context) {
207
+ var _named, _target_name, _patch_name;
208
+ const active = activePipelines(pipelines);
209
+ const named = (name)=>name ? active.find((one)=>{
210
+ var _one_name;
211
+ return same(String((_one_name = one.name) != null ? _one_name : ''), name);
212
+ }) : undefined;
213
+ const target = (_named = named(setup.renameFrom)) != null ? _named : named(setup.name);
214
+ const defaultsOff = (keep)=>active.filter((one)=>one.isDefault && one.$id !== keep).map((one)=>one.$id);
215
+ if (!target) {
216
+ const problem = pipelineNameProblem(setup.name, pipelines);
217
+ if (problem) return {
218
+ kind: 'refuse',
219
+ error: problem
220
+ };
221
+ // Stages asked for replace the default open stages; none asked keeps
222
+ // them all, as the console's New pipeline does.
223
+ const stages = mergeCrmSetupStages(setup.stages.length ? DEFAULT_DEAL_STAGES.filter((stage)=>stage.kind !== 'open') : DEFAULT_DEAL_STAGES, setup);
224
+ const invalid = stagesProblem(stages);
225
+ if (invalid) return {
226
+ kind: 'refuse',
227
+ error: invalid
228
+ };
229
+ const isDefault = setup.makeDefault || active.length === 0;
230
+ return {
231
+ kind: 'create',
232
+ document: _extends({}, newPipelineDocument(setup.name, context), {
233
+ stages,
234
+ isDefault
235
+ }),
236
+ defaultsOff: isDefault ? defaultsOff(null) : []
237
+ };
238
+ }
239
+ const patch = {};
240
+ // A name that differs only in case is the same pipeline, and keeps its own.
241
+ if (!same(String((_target_name = target.name) != null ? _target_name : ''), setup.name)) {
242
+ const problem = pipelineNameProblem(setup.name, pipelines, target.$id);
243
+ if (problem) return {
244
+ kind: 'refuse',
245
+ error: problem
246
+ };
247
+ patch.name = setup.name;
248
+ }
249
+ const current = sortedStages(target);
250
+ const stages = mergeCrmSetupStages(current, setup);
251
+ const invalid = stagesProblem(stages);
252
+ if (invalid) return {
253
+ kind: 'refuse',
254
+ error: invalid
255
+ };
256
+ if (JSON.stringify(stages) !== JSON.stringify(renumberStages(current))) patch.stages = stages;
257
+ if (setup.makeDefault && !target.isDefault) patch.isDefault = true;
258
+ return {
259
+ kind: 'update',
260
+ id: target.$id,
261
+ name: (_patch_name = patch.name) != null ? _patch_name : target.name,
262
+ patch,
263
+ defaultsOff: patch.isDefault ? defaultsOff(target.$id) : []
264
+ };
265
+ }
266
+ /** What a pipeline's writer reports: its open stages in order, and its closing stages. */ export function crmPipelineSetupFacts(stages) {
267
+ var _ref, _ref1;
268
+ var _sorted_find, _sorted_find1;
269
+ const sorted = sortedStages({
270
+ stages: [
271
+ ...stages
272
+ ]
273
+ });
274
+ return {
275
+ stages: sorted.filter((stage)=>stage.kind === 'open').map((stage)=>`${stage.name} (${stage.probability}%)`),
276
+ wonStage: (_ref = (_sorted_find = sorted.find((stage)=>stage.kind === 'won')) == null ? void 0 : _sorted_find.name) != null ? _ref : null,
277
+ lostStage: (_ref1 = (_sorted_find1 = sorted.find((stage)=>stage.kind === 'lost')) == null ? void 0 : _sorted_find1.name) != null ? _ref1 : null
278
+ };
279
+ }
280
+ // ── Custom fields ─────────────────────────────────────────────────────────
281
+ /** The resource the CRM's custom field writer is registered under. */ export const CRM_FIELD_SETUP_RESOURCE = 'crm-field';
282
+ /** How many choices a Choice field may declare, and how long each and a label may be: the field drawer's. */ export const CRM_FIELD_OPTIONS_MAX = 50;
283
+ export const CRM_FIELD_OPTION_MAX_LENGTH = 120;
284
+ export const CRM_FIELD_LABEL_MAX_LENGTH = 80;
285
+ /** One choice per entry, trimmed, blanks dropped, duplicates collapsed, at most the drawer's count. */ export function crmFieldOptions(entries) {
286
+ const seen = new Set();
287
+ const options = [];
288
+ for (const entry of entries){
289
+ const option = String(entry != null ? entry : '').trim().slice(0, CRM_FIELD_OPTION_MAX_LENGTH);
290
+ if (!option || seen.has(option)) continue;
291
+ seen.add(option);
292
+ options.push(option);
293
+ if (options.length >= CRM_FIELD_OPTIONS_MAX) break;
294
+ }
295
+ return options;
296
+ }
297
+ /** A field's content, read and judged. Pure. */ export function readCrmFieldSetup(content) {
298
+ const problems = [];
299
+ const object = content['object'];
300
+ if (!isCrmFieldObject(object)) problems.push(`A field describes one of ${CRM_FIELD_OBJECTS.join(', ')}.`);
301
+ const type = content['type'];
302
+ if (!isContactFieldType(type)) problems.push(`A field's type is one of ${CONTACT_FIELD_TYPES.join(', ')}.`);
303
+ const label = text(content['label']);
304
+ const key = normalizeContactFieldKey(label);
305
+ if (!label) problems.push('A field needs a label.');
306
+ else if (label.length > CRM_FIELD_LABEL_MAX_LENGTH) {
307
+ problems.push(`A field's label is at most ${CRM_FIELD_LABEL_MAX_LENGTH} characters.`);
308
+ } else if (!key) problems.push('A field’s label needs a letter to make its key from.');
309
+ const listed = Array.isArray(content['options']) ? content['options'].filter((one)=>typeof one === 'string') : [];
310
+ const options = type === 'select' ? crmFieldOptions(listed) : [];
311
+ if (type === 'select' && !options.length) problems.push('A Choice field needs its choices.');
312
+ if (problems.length) return {
313
+ ok: false,
314
+ problems
315
+ };
316
+ return {
317
+ ok: true,
318
+ value: {
319
+ object: object,
320
+ label,
321
+ key: key,
322
+ type: type,
323
+ options,
324
+ required: content['required'] === true
325
+ }
326
+ };
327
+ }
328
+ /** The `crm-field` writer's check: whether the content is a field ask, with the key it derives. Pure. */ export function checkCrmFieldSetup(content) {
329
+ const read = readCrmFieldSetup(content);
330
+ if (read.ok === false) return {
331
+ ok: false,
332
+ problems: read.problems
333
+ };
334
+ return {
335
+ ok: true,
336
+ facts: {
337
+ object: read.value.object,
338
+ key: read.value.key,
339
+ type: read.value.type
340
+ }
341
+ };
342
+ }
343
+ /**
344
+ * Create the field, or find it. Pure.
345
+ *
346
+ * An active field on the same object with the same label or key is the
347
+ * field: a re-run makes nothing, and a Choice field gains only the choices it
348
+ * lacks. A retired field keeps its key, as the drawer says, so a key a
349
+ * retired field holds is refused rather than reused — its old values would
350
+ * read back as the new field's. The org's ceiling counts every definition,
351
+ * retired ones included, as the Fields section's read does.
352
+ */ export function planCrmFieldSetup(setup, definitions, context) {
353
+ const ofObject = fieldDefinitionsForObject(definitions, setup.object);
354
+ const match = ofObject.find((one)=>{
355
+ var _one_label;
356
+ return !one.retiredAt && (same(String((_one_label = one.label) != null ? _one_label : ''), setup.label) || one.key === setup.key);
357
+ });
358
+ if (match) {
359
+ var _match_options;
360
+ const missing = match.type === 'select' && setup.type === 'select' ? setup.options.filter((option)=>{
361
+ var _match_options;
362
+ return !((_match_options = match.options) != null ? _match_options : []).includes(option);
363
+ }) : [];
364
+ const room = Math.max(0, CRM_FIELD_OPTIONS_MAX - ((_match_options = match.options) != null ? _match_options : []).length);
365
+ return {
366
+ kind: 'existing',
367
+ id: match.$id,
368
+ label: match.label,
369
+ options: missing.slice(0, room)
370
+ };
371
+ }
372
+ const retired = ofObject.find((one)=>one.retiredAt && one.key === setup.key);
373
+ if (retired) {
374
+ return {
375
+ kind: 'refuse',
376
+ status: 409,
377
+ error: `A retired ${CRM_FIELD_OBJECT_LABELS[setup.object].toLowerCase()} field, “${retired.label}”, keeps the key ` + `“${setup.key}”. Restore it in CRM → Fields, or give this field another name.`
378
+ };
379
+ }
380
+ if (definitions.length >= CONTACT_FIELDS_MAX_PER_ORG) {
381
+ return {
382
+ kind: 'refuse',
383
+ status: 403,
384
+ error: `An organization keeps at most ${CONTACT_FIELDS_MAX_PER_ORG} custom fields. Retire or delete one in CRM → Fields first.`
385
+ };
386
+ }
387
+ const order = ofObject.reduce((highest, one)=>Math.max(highest, Number(one.order) || 0), -1) + 1;
388
+ return {
389
+ kind: 'create',
390
+ document: _extends({
391
+ key: setup.key,
392
+ label: setup.label,
393
+ type: setup.type
394
+ }, setup.type === 'select' ? {
395
+ options: setup.options
396
+ } : {}, {
397
+ required: setup.required,
398
+ order,
399
+ retiredAt: null
400
+ }, crmFieldListFields({
401
+ key: setup.key,
402
+ label: setup.label,
403
+ required: setup.required,
404
+ object: setup.object
405
+ }), {
406
+ hostId: context.hostId,
407
+ visibleTo: [
408
+ ...context.visibleTo
409
+ ],
410
+ createdAt: context.now,
411
+ updatedAt: context.now
412
+ })
413
+ };
414
+ }
415
+
416
+ //# sourceMappingURL=crm-setup-content.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/crm-setup-content.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// Modules by their own paths, never the `@aglyn/aglyn` barrel: the console\n// server's boot reaches this file through the CRM's setup writers.\nimport {\n CONTACT_FIELD_TYPES,\n CONTACT_FIELDS_MAX_PER_ORG,\n fieldDefinitionsForObject,\n isContactFieldType,\n} from '@aglyn/aglyn/app-utils/contact-custom-fields'\nimport {\n type ContactFieldDefinition,\n type ContactFieldType,\n type CrmDealStage,\n type CrmFieldObject,\n CRM_FIELD_OBJECT_LABELS,\n CRM_FIELD_OBJECTS,\n crmFieldListFields,\n DEFAULT_DEAL_STAGES,\n isCrmFieldObject,\n normalizeContactFieldKey,\n} from '@aglyn/aglyn/app-utils/crm'\nimport type { PluginDraftCheck } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport {\n activePipelines,\n clampProbability,\n newPipelineDocument,\n newStageId,\n PIPELINE_NAME_MAX,\n type PipelineDoc,\n type PipelineWriteContext,\n pipelineNameProblem,\n renumberStages,\n sortedStages,\n stagesProblem,\n} from './deal-board-model'\n\n/**\n * CRM SETUP ANOTHER PLUGIN ASKS FOR (AGL-3746): a deal pipeline with its\n * stages, and a custom field on a CRM object — what a person sets up in\n * CRM → Deals → Pipelines and CRM → Fields, as pure rules.\n *\n * Aglyn AI's build asks for them through the CRM's writers on the\n * resource-drafts seam (`server/crm-setup-writers.server.ts`). Neither has a\n * draft state: a pipeline and a field are in use once saved, as the console's\n * own saves are. So every rule here is the console's, read from the modules\n * the console reads:\n *\n * - A PIPELINE is `newPipelineDocument` with the stages asked for in place\n * of the default set, held to `stagesProblem` (exactly one Won and one\n * Lost, at least one open stage) and named under `pipelineNameProblem`\n * (unique among the active pipelines, without case). Stage ids are\n * `newStageId`'s slugs.\n * - A FIELD is the Fields section's create: a key derived from the label by\n * `normalizeContactFieldKey`, one of `CONTACT_FIELD_TYPES`, the choices\n * the drawer allows, landing last on its object's tab, org-wide.\n *\n * IDEMPOTENT BY NAME. Asked twice, nothing is made twice: an active pipeline\n * is matched by name and a stage within it by name, a field by label or key\n * within its object, all without case. A match is brought to what was\n * asked — missing stages added, stages put in the asked order, a\n * probability set — and nothing is ever removed: a stage may hold deals and\n * a field values, and removing either is a person's decision.\n */\n\n// ── Pipelines ─────────────────────────────────────────────────────────────\n\n/** The resource the CRM's pipeline writer is registered under. */\nexport const CRM_PIPELINE_SETUP_RESOURCE = 'crm-pipeline'\n\n/** The longest stage name, as long as a pipeline's name. */\nexport const CRM_SETUP_STAGE_NAME_MAX = PIPELINE_NAME_MAX\n\n/** The most open stages one ask sets; a board wider than this does not read. */\nexport const CRM_SETUP_OPEN_STAGES_MAX = 12\n\n/** A pipeline as one ask states it, once read. */\nexport interface CrmPipelineSetup {\n name: string\n /** An active pipeline to rename to `name`, by its current name. */\n renameFrom: string | null\n /** The open stages, in order, each with the probability asked for, if any. */\n stages: Array<{ name: string; probability: number | null }>\n /** What the Won and Lost stages are called, where the ask says. */\n wonStage: string | null\n lostStage: string | null\n /** Whether this becomes the pipeline a new deal lands in. */\n makeDefault: boolean\n}\n\nconst WON_NAME = /^(closed[\\s-]*)?won$/i\nconst LOST_NAME = /^(closed[\\s-]*)?lost$/i\n/** \"Discovery (20%)\": a stage's name with its probability. */\nconst STAGE_WITH_PROBABILITY = /^(.*?)\\s*\\(\\s*(\\d{1,3}(?:\\.\\d+)?)\\s*%?\\s*\\)\\s*$/\n\nfunction text(value: unknown): string {\n return typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''\n}\n\nconst same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()\n\n/** One listed stage, read: its name and the probability written after it. */\nexport function readCrmSetupStage(entry: string): { name: string; probability: number | null } {\n const raw = text(entry)\n const match = STAGE_WITH_PROBABILITY.exec(raw)\n if (!match) return { name: raw, probability: null }\n return { name: text(match[1]), probability: clampProbability(match[2]) }\n}\n\n/**\n * A pipeline's content, read and judged. Pure. A stage listed as Won or\n * Lost (\"Won\", \"Closed Lost\") names the closing stage rather than adding an\n * open one, since every pipeline already has exactly one of each.\n */\nexport function readCrmPipelineSetup(\n content: Readonly<Record<string, unknown>>,\n): { ok: true; value: CrmPipelineSetup } | { ok: false; problems: string[] } {\n const problems: string[] = []\n const name = text(content['name'])\n if (!name) problems.push('A pipeline needs a name.')\n else if (name.length > PIPELINE_NAME_MAX) problems.push(`A pipeline's name is at most ${PIPELINE_NAME_MAX} characters.`)\n let wonStage = text(content['wonStage']) || null\n let lostStage = text(content['lostStage']) || null\n const listed = Array.isArray(content['stages']) ? content['stages'] : []\n const stages: CrmPipelineSetup['stages'] = []\n for (const entry of listed) {\n if (typeof entry !== 'string') {\n problems.push('Each stage is a name.')\n break\n }\n const stage = readCrmSetupStage(entry)\n if (!stage.name) continue\n if (WON_NAME.test(stage.name)) {\n wonStage ??= stage.name\n continue\n }\n if (LOST_NAME.test(stage.name)) {\n lostStage ??= stage.name\n continue\n }\n if (stage.name.length > CRM_SETUP_STAGE_NAME_MAX) {\n problems.push(`A stage's name is at most ${CRM_SETUP_STAGE_NAME_MAX} characters.`)\n break\n }\n if (stages.some((one) => same(one.name, stage.name))) continue\n stages.push(stage)\n }\n if (stages.length > CRM_SETUP_OPEN_STAGES_MAX) {\n problems.push(`A pipeline is set up with at most ${CRM_SETUP_OPEN_STAGES_MAX} open stages.`)\n }\n for (const closing of [wonStage, lostStage]) {\n if (closing && closing.length > CRM_SETUP_STAGE_NAME_MAX) {\n problems.push(`A stage's name is at most ${CRM_SETUP_STAGE_NAME_MAX} characters.`)\n }\n }\n if (wonStage && lostStage && same(wonStage, lostStage)) problems.push('The Won and Lost stages need different names.')\n if (problems.length) return { ok: false, problems }\n return {\n ok: true,\n value: {\n name,\n renameFrom: text(content['renameFrom']) || null,\n stages,\n wonStage,\n lostStage,\n makeDefault: content['makeDefault'] === true,\n },\n }\n}\n\n/** The `crm-pipeline` writer's check: whether the content is a pipeline ask, with its name and stages. Pure. */\nexport function checkCrmPipelineSetup(content: Readonly<Record<string, unknown>>): PluginDraftCheck {\n const read = readCrmPipelineSetup(content)\n if (read.ok === false) return { ok: false, problems: read.problems }\n return { ok: true, facts: { name: read.value.name, stages: read.value.stages.map((one) => one.name) } }\n}\n\n/**\n * The probability an open stage takes when the ask names none: spread over\n * the pipeline by position, to the nearest 5 — the first of four stages 20,\n * the last 80 — so a weighted forecast grows as a deal moves right.\n */\nexport function crmSetupStageProbability(index: number, count: number): number {\n const spread = ((index + 1) / (count + 1)) * 100\n return Math.min(95, Math.max(5, Math.round(spread / 5) * 5))\n}\n\n/**\n * The stages a pipeline ends up with: the open stages asked for, in the asked\n * order, each an existing stage of that name where there is one (its id kept,\n * so the deals in it stay), then the open stages the ask did not name, as\n * they were, then Won and Lost, renamed where asked. Nothing is removed.\n */\nexport function mergeCrmSetupStages(\n existing: readonly CrmDealStage[],\n setup: Pick<CrmPipelineSetup, 'stages' | 'wonStage' | 'lostStage'>,\n): CrmDealStage[] {\n const sorted = sortedStages({ stages: [...existing] })\n const opens = sorted.filter((stage) => stage.kind === 'open')\n const taken: CrmDealStage[] = [...sorted]\n const listed: CrmDealStage[] = setup.stages.map((asked, index) => {\n const found = opens.find((stage) => same(stage.name, asked.name))\n if (found) return asked.probability === null ? found : { ...found, probability: asked.probability }\n const stage: CrmDealStage = {\n id: newStageId(asked.name, taken),\n name: asked.name,\n order: 0,\n probability: asked.probability ?? crmSetupStageProbability(index, setup.stages.length),\n kind: 'open',\n forecastCategory: 'pipeline',\n }\n taken.push(stage)\n return stage\n })\n const unlisted = opens.filter((stage) => !listed.some((one) => one.id === stage.id))\n const closing = (kind: 'won' | 'lost', name: string | null): CrmDealStage[] =>\n sorted\n .filter((stage) => stage.kind === kind)\n .map((stage) => (name ? { ...stage, name } : stage))\n return renumberStages([\n ...listed,\n ...unlisted,\n ...closing('won', setup.wonStage),\n ...closing('lost', setup.lostStage),\n ])\n}\n\n/** What saving a pipeline ask comes to against the pipelines the member's site can read. */\nexport type CrmPipelineSetupPlan =\n | {\n kind: 'create'\n document: Record<string, unknown>\n /** Pipelines whose default flag comes off, because this one takes it. */\n defaultsOff: string[]\n }\n | {\n kind: 'update'\n id: string\n name: string\n /** The fields that change; empty when the pipeline already is what was asked. */\n patch: { name?: string; stages?: CrmDealStage[]; isDefault?: true }\n defaultsOff: string[]\n }\n | { kind: 'refuse'; error: string }\n\n/**\n * Create, or bring an existing pipeline to the ask. Pure.\n *\n * The pipeline is the active one called `renameFrom`, else the active one\n * called `name`; with neither, a new one, which is also the default when the\n * site reads no active pipeline yet (the board's seed would have made it so)\n * or when asked.\n */\nexport function planCrmPipelineSetup(\n setup: CrmPipelineSetup,\n pipelines: readonly PipelineDoc[],\n context: PipelineWriteContext,\n): CrmPipelineSetupPlan {\n const active = activePipelines(pipelines)\n const named = (name: string | null) => (name ? active.find((one) => same(String(one.name ?? ''), name)) : undefined)\n const target = named(setup.renameFrom) ?? named(setup.name)\n const defaultsOff = (keep: string | null) =>\n active.filter((one) => one.isDefault && one.$id !== keep).map((one) => one.$id)\n\n if (!target) {\n const problem = pipelineNameProblem(setup.name, pipelines)\n if (problem) return { kind: 'refuse', error: problem }\n // Stages asked for replace the default open stages; none asked keeps\n // them all, as the console's New pipeline does.\n const stages = mergeCrmSetupStages(\n setup.stages.length ? DEFAULT_DEAL_STAGES.filter((stage) => stage.kind !== 'open') : DEFAULT_DEAL_STAGES,\n setup,\n )\n const invalid = stagesProblem(stages)\n if (invalid) return { kind: 'refuse', error: invalid }\n const isDefault = setup.makeDefault || active.length === 0\n return {\n kind: 'create',\n document: { ...newPipelineDocument(setup.name, context), stages, isDefault },\n defaultsOff: isDefault ? defaultsOff(null) : [],\n }\n }\n\n const patch: { name?: string; stages?: CrmDealStage[]; isDefault?: true } = {}\n // A name that differs only in case is the same pipeline, and keeps its own.\n if (!same(String(target.name ?? ''), setup.name)) {\n const problem = pipelineNameProblem(setup.name, pipelines, target.$id)\n if (problem) return { kind: 'refuse', error: problem }\n patch.name = setup.name\n }\n const current = sortedStages(target)\n const stages = mergeCrmSetupStages(current, setup)\n const invalid = stagesProblem(stages)\n if (invalid) return { kind: 'refuse', error: invalid }\n if (JSON.stringify(stages) !== JSON.stringify(renumberStages(current))) patch.stages = stages\n if (setup.makeDefault && !target.isDefault) patch.isDefault = true\n return {\n kind: 'update',\n id: target.$id,\n name: patch.name ?? target.name,\n patch,\n defaultsOff: patch.isDefault ? defaultsOff(target.$id) : [],\n }\n}\n\n/** What a pipeline's writer reports: its open stages in order, and its closing stages. */\nexport function crmPipelineSetupFacts(stages: readonly CrmDealStage[]): Readonly<Record<string, unknown>> {\n const sorted = sortedStages({ stages: [...stages] })\n return {\n stages: sorted.filter((stage) => stage.kind === 'open').map((stage) => `${stage.name} (${stage.probability}%)`),\n wonStage: sorted.find((stage) => stage.kind === 'won')?.name ?? null,\n lostStage: sorted.find((stage) => stage.kind === 'lost')?.name ?? null,\n }\n}\n\n// ── Custom fields ─────────────────────────────────────────────────────────\n\n/** The resource the CRM's custom field writer is registered under. */\nexport const CRM_FIELD_SETUP_RESOURCE = 'crm-field'\n\n/** How many choices a Choice field may declare, and how long each and a label may be: the field drawer's. */\nexport const CRM_FIELD_OPTIONS_MAX = 50\nexport const CRM_FIELD_OPTION_MAX_LENGTH = 120\nexport const CRM_FIELD_LABEL_MAX_LENGTH = 80\n\n/** One choice per entry, trimmed, blanks dropped, duplicates collapsed, at most the drawer's count. */\nexport function crmFieldOptions(entries: readonly string[]): string[] {\n const seen = new Set<string>()\n const options: string[] = []\n for (const entry of entries) {\n const option = String(entry ?? '').trim().slice(0, CRM_FIELD_OPTION_MAX_LENGTH)\n if (!option || seen.has(option)) continue\n seen.add(option)\n options.push(option)\n if (options.length >= CRM_FIELD_OPTIONS_MAX) break\n }\n return options\n}\n\n/** A custom field as one ask states it, once read. */\nexport interface CrmFieldSetup {\n object: CrmFieldObject\n label: string\n /** The key its values are stored under, derived from the label. */\n key: string\n type: ContactFieldType\n /** The choices, for `select`; empty otherwise. */\n options: string[]\n required: boolean\n}\n\n/** A field's content, read and judged. Pure. */\nexport function readCrmFieldSetup(\n content: Readonly<Record<string, unknown>>,\n): { ok: true; value: CrmFieldSetup } | { ok: false; problems: string[] } {\n const problems: string[] = []\n const object = content['object']\n if (!isCrmFieldObject(object)) problems.push(`A field describes one of ${CRM_FIELD_OBJECTS.join(', ')}.`)\n const type = content['type']\n if (!isContactFieldType(type)) problems.push(`A field's type is one of ${CONTACT_FIELD_TYPES.join(', ')}.`)\n const label = text(content['label'])\n const key = normalizeContactFieldKey(label)\n if (!label) problems.push('A field needs a label.')\n else if (label.length > CRM_FIELD_LABEL_MAX_LENGTH) {\n problems.push(`A field's label is at most ${CRM_FIELD_LABEL_MAX_LENGTH} characters.`)\n } else if (!key) problems.push('A field’s label needs a letter to make its key from.')\n const listed = Array.isArray(content['options']) ? content['options'].filter((one): one is string => typeof one === 'string') : []\n const options = type === 'select' ? crmFieldOptions(listed) : []\n if (type === 'select' && !options.length) problems.push('A Choice field needs its choices.')\n if (problems.length) return { ok: false, problems }\n return {\n ok: true,\n value: {\n object: object as CrmFieldObject,\n label,\n key: key as string,\n type: type as ContactFieldType,\n options,\n required: content['required'] === true,\n },\n }\n}\n\n/** The `crm-field` writer's check: whether the content is a field ask, with the key it derives. Pure. */\nexport function checkCrmFieldSetup(content: Readonly<Record<string, unknown>>): PluginDraftCheck {\n const read = readCrmFieldSetup(content)\n if (read.ok === false) return { ok: false, problems: read.problems }\n return { ok: true, facts: { object: read.value.object, key: read.value.key, type: read.value.type } }\n}\n\n/** A stored definition, as the writer reads one: its document id beside it. */\nexport type CrmFieldDefinitionDoc = ContactFieldDefinition & { $id: string }\n\n/** What saving a field ask comes to against every definition the org holds. */\nexport type CrmFieldSetupPlan =\n | { kind: 'create'; document: Record<string, unknown> }\n /** The field exists; `options` are the choices its Choice list gains, empty when none. */\n | { kind: 'existing'; id: string; label: string; options: string[] }\n | { kind: 'refuse'; status: 403 | 409; error: string }\n\n/**\n * Create the field, or find it. Pure.\n *\n * An active field on the same object with the same label or key is the\n * field: a re-run makes nothing, and a Choice field gains only the choices it\n * lacks. A retired field keeps its key, as the drawer says, so a key a\n * retired field holds is refused rather than reused — its old values would\n * read back as the new field's. The org's ceiling counts every definition,\n * retired ones included, as the Fields section's read does.\n */\nexport function planCrmFieldSetup(\n setup: CrmFieldSetup,\n definitions: readonly CrmFieldDefinitionDoc[],\n context: { hostId: string; now: Date; visibleTo: readonly string[] },\n): CrmFieldSetupPlan {\n const ofObject = fieldDefinitionsForObject(definitions, setup.object)\n const match = ofObject.find(\n (one) => !one.retiredAt && (same(String(one.label ?? ''), setup.label) || one.key === setup.key),\n )\n if (match) {\n const missing =\n match.type === 'select' && setup.type === 'select'\n ? setup.options.filter((option) => !(match.options ?? []).includes(option))\n : []\n const room = Math.max(0, CRM_FIELD_OPTIONS_MAX - (match.options ?? []).length)\n return { kind: 'existing', id: match.$id, label: match.label, options: missing.slice(0, room) }\n }\n const retired = ofObject.find((one) => one.retiredAt && one.key === setup.key)\n if (retired) {\n return {\n kind: 'refuse',\n status: 409,\n error:\n `A retired ${CRM_FIELD_OBJECT_LABELS[setup.object].toLowerCase()} field, “${retired.label}”, keeps the key ` +\n `“${setup.key}”. Restore it in CRM → Fields, or give this field another name.`,\n }\n }\n if (definitions.length >= CONTACT_FIELDS_MAX_PER_ORG) {\n return {\n kind: 'refuse',\n status: 403,\n error: `An organization keeps at most ${CONTACT_FIELDS_MAX_PER_ORG} custom fields. Retire or delete one in CRM → Fields first.`,\n }\n }\n const order = ofObject.reduce((highest, one) => Math.max(highest, Number(one.order) || 0), -1) + 1\n return {\n kind: 'create',\n document: {\n key: setup.key,\n label: setup.label,\n type: setup.type,\n ...(setup.type === 'select' ? { options: setup.options } : {}),\n required: setup.required,\n order,\n retiredAt: null,\n ...crmFieldListFields({ key: setup.key, label: setup.label, required: setup.required, object: setup.object }),\n hostId: context.hostId,\n visibleTo: [...context.visibleTo],\n createdAt: context.now,\n updatedAt: context.now,\n },\n }\n}\n"],"names":["CONTACT_FIELD_TYPES","CONTACT_FIELDS_MAX_PER_ORG","fieldDefinitionsForObject","isContactFieldType","CRM_FIELD_OBJECT_LABELS","CRM_FIELD_OBJECTS","crmFieldListFields","DEFAULT_DEAL_STAGES","isCrmFieldObject","normalizeContactFieldKey","activePipelines","clampProbability","newPipelineDocument","newStageId","PIPELINE_NAME_MAX","pipelineNameProblem","renumberStages","sortedStages","stagesProblem","CRM_PIPELINE_SETUP_RESOURCE","CRM_SETUP_STAGE_NAME_MAX","CRM_SETUP_OPEN_STAGES_MAX","WON_NAME","LOST_NAME","STAGE_WITH_PROBABILITY","text","value","replace","trim","same","a","b","toLowerCase","readCrmSetupStage","entry","raw","match","exec","name","probability","readCrmPipelineSetup","content","problems","push","length","wonStage","lostStage","listed","Array","isArray","stages","stage","test","some","one","closing","ok","renameFrom","makeDefault","checkCrmPipelineSetup","read","facts","map","crmSetupStageProbability","index","count","spread","Math","min","max","round","mergeCrmSetupStages","existing","setup","sorted","opens","filter","kind","taken","asked","found","find","id","order","forecastCategory","unlisted","planCrmPipelineSetup","pipelines","context","named","target","patch","active","String","undefined","defaultsOff","keep","isDefault","$id","problem","error","invalid","document","current","JSON","stringify","crmPipelineSetupFacts","CRM_FIELD_SETUP_RESOURCE","CRM_FIELD_OPTIONS_MAX","CRM_FIELD_OPTION_MAX_LENGTH","CRM_FIELD_LABEL_MAX_LENGTH","crmFieldOptions","entries","seen","Set","options","option","slice","has","add","readCrmFieldSetup","object","join","type","label","key","required","checkCrmFieldSetup","planCrmFieldSetup","definitions","ofObject","retiredAt","missing","includes","room","retired","status","reduce","highest","Number","hostId","visibleTo","createdAt","now","updatedAt"],"mappings":"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"}
@@ -0,0 +1,55 @@
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 PluginAiCapability, type PluginAiCapabilityArgs } from '@aglyn/aglyn/plugin-manager/plugin-ai-capabilities';
18
+ /**
19
+ * WHAT AN AI BUILD CAN SET UP IN THE CRM (AGL-3746): a deal pipeline with its
20
+ * stages, and a custom field on a contact, company, deal or lead.
21
+ *
22
+ * The capabilities the AI plugin's build planner offers for "set up a sales
23
+ * pipeline for my agency: Discovery, Proposal, Negotiation, Won/Lost" or
24
+ * "add a Budget field to deals". Each item is written by this plugin's
25
+ * `crm-pipeline` or `crm-field` writer, so every rule is the writer's and the
26
+ * console's: the `crm` plan feature, an owner, admin or editor with
27
+ * `data.manage`, the scope, `stagesProblem`, the field types and the org's
28
+ * 100-field ceiling. Both are saved live, as the console saves them (`live`):
29
+ * neither has a draft state. Matched by name, so asking again changes
30
+ * nothing twice.
31
+ *
32
+ * The arguments are flat, as the contract requires, and are the setup's own
33
+ * fields; the planner fills them, so no model runs (`estimateCredits` 0). No
34
+ * page block shows either, so neither changes what a visitor sees.
35
+ *
36
+ * A Salesforce "opportunity" is a deal here and an "account" a company; the
37
+ * descriptions say so, since a request may use either word.
38
+ */
39
+ /** The operations' names in a build plan. */
40
+ export declare const CRM_PIPELINE_AI_OP = "crm-pipeline";
41
+ export declare const CRM_FIELD_AI_OP = "crm-field";
42
+ /** How many of each one build may set up. */
43
+ export declare const CRM_PIPELINE_AI_MAX_PER_PLAN = 3;
44
+ export declare const CRM_FIELD_AI_MAX_PER_PLAN = 12;
45
+ /** The pipeline writer's content for an item: its arguments, as they are. Pure. */
46
+ export declare function crmPipelineSetupContentFromArgs(args: PluginAiCapabilityArgs): Readonly<Record<string, unknown>>;
47
+ /** The field writer's content for an item: its arguments, as they are. Pure. */
48
+ export declare function crmFieldSetupContentFromArgs(args: PluginAiCapabilityArgs): Readonly<Record<string, unknown>>;
49
+ export declare const crmPipelineAiCapability: PluginAiCapability;
50
+ export declare const crmFieldAiCapability: PluginAiCapability;
51
+ /**
52
+ * Registers both capabilities; the console-server declarations call it beside
53
+ * the writers, since only the console runs AI jobs (AGL-3026). Idempotent.
54
+ */
55
+ export declare function registerCrmSetupAiCapabilities(): void;