@ketvietlab/ketsuite 0.1.22 → 0.1.24

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.
Files changed (233) hide show
  1. package/dist/cli.js +9 -3
  2. package/dist/cli.js.map +1 -1
  3. package/dist/demo/crm.d.ts +3 -0
  4. package/dist/demo/crm.d.ts.map +1 -0
  5. package/dist/demo/crm.js +418 -0
  6. package/dist/demo/crm.js.map +1 -0
  7. package/dist/demo/partner.d.ts +8 -0
  8. package/dist/demo/partner.d.ts.map +1 -0
  9. package/dist/demo/partner.js +179 -0
  10. package/dist/demo/partner.js.map +1 -0
  11. package/dist/demo/product.d.ts +25 -0
  12. package/dist/demo/product.d.ts.map +1 -0
  13. package/dist/demo/product.js +579 -0
  14. package/dist/demo/product.js.map +1 -0
  15. package/dist/demo/purchase.d.ts +4 -0
  16. package/dist/demo/purchase.d.ts.map +1 -0
  17. package/dist/demo/purchase.js +58 -0
  18. package/dist/demo/purchase.js.map +1 -0
  19. package/dist/demo/sale.d.ts +4 -0
  20. package/dist/demo/sale.d.ts.map +1 -0
  21. package/dist/demo/sale.js +52 -0
  22. package/dist/demo/sale.js.map +1 -0
  23. package/dist/demo/seed.d.ts +14 -0
  24. package/dist/demo/seed.d.ts.map +1 -0
  25. package/dist/demo/seed.js +88 -0
  26. package/dist/demo/seed.js.map +1 -0
  27. package/dist/demo/stock.d.ts +7 -0
  28. package/dist/demo/stock.d.ts.map +1 -0
  29. package/dist/demo/stock.js +26 -0
  30. package/dist/demo/stock.js.map +1 -0
  31. package/dist/demo/user.d.ts +4 -0
  32. package/dist/demo/user.d.ts.map +1 -0
  33. package/dist/demo/user.js +41 -0
  34. package/dist/demo/user.js.map +1 -0
  35. package/dist/demo/util.d.ts +7 -0
  36. package/dist/demo/util.d.ts.map +1 -0
  37. package/dist/demo/util.js +19 -0
  38. package/dist/demo/util.js.map +1 -0
  39. package/dist/modules/backend/design/client/backend-shell.d.mts +2 -2
  40. package/dist/modules/backend/design/client/backend-shell.d.mts.map +1 -1
  41. package/dist/modules/backend/design/client/backend-shell.mjs +1 -1
  42. package/dist/modules/backend/design/client/backend-shell.mjs.map +3 -3
  43. package/dist/modules/backend/design/forms.css +7 -0
  44. package/dist/modules/backend/screen.d.ts.map +1 -1
  45. package/dist/modules/backend/screen.js +11 -2
  46. package/dist/modules/backend/screen.js.map +1 -1
  47. package/dist/modules/crm/functions/access.d.ts +3 -0
  48. package/dist/modules/crm/functions/access.d.ts.map +1 -0
  49. package/dist/modules/crm/functions/access.js +89 -0
  50. package/dist/modules/crm/functions/access.js.map +1 -0
  51. package/dist/modules/crm/functions/activities.d.ts +3 -0
  52. package/dist/modules/crm/functions/activities.d.ts.map +1 -0
  53. package/dist/modules/crm/functions/activities.js +174 -0
  54. package/dist/modules/crm/functions/activities.js.map +1 -0
  55. package/dist/modules/crm/functions/cases.d.ts +3 -0
  56. package/dist/modules/crm/functions/cases.d.ts.map +1 -0
  57. package/dist/modules/crm/functions/cases.js +585 -0
  58. package/dist/modules/crm/functions/cases.js.map +1 -0
  59. package/dist/modules/crm/functions/configuration.d.ts +3 -0
  60. package/dist/modules/crm/functions/configuration.d.ts.map +1 -0
  61. package/dist/modules/crm/functions/configuration.js +331 -0
  62. package/dist/modules/crm/functions/configuration.js.map +1 -0
  63. package/dist/modules/crm/functions/gamification.d.ts +3 -0
  64. package/dist/modules/crm/functions/gamification.d.ts.map +1 -0
  65. package/dist/modules/crm/functions/gamification.js +58 -0
  66. package/dist/modules/crm/functions/gamification.js.map +1 -0
  67. package/dist/modules/crm/functions/index.d.ts +4 -0
  68. package/dist/modules/crm/functions/index.d.ts.map +1 -0
  69. package/dist/modules/crm/functions/index.js +33 -0
  70. package/dist/modules/crm/functions/index.js.map +1 -0
  71. package/dist/modules/crm/functions/modal-context.d.ts +3 -0
  72. package/dist/modules/crm/functions/modal-context.d.ts.map +1 -0
  73. package/dist/modules/crm/functions/modal-context.js +248 -0
  74. package/dist/modules/crm/functions/modal-context.js.map +1 -0
  75. package/dist/modules/crm/functions/previews.d.ts +3 -0
  76. package/dist/modules/crm/functions/previews.d.ts.map +1 -0
  77. package/dist/modules/crm/functions/previews.js +20 -0
  78. package/dist/modules/crm/functions/previews.js.map +1 -0
  79. package/dist/modules/crm/functions/shared.d.ts +31 -0
  80. package/dist/modules/crm/functions/shared.d.ts.map +1 -0
  81. package/dist/modules/crm/functions/shared.js +194 -0
  82. package/dist/modules/crm/functions/shared.js.map +1 -0
  83. package/dist/modules/crm/index.d.ts +1 -1
  84. package/dist/modules/crm/index.d.ts.map +1 -1
  85. package/dist/modules/crm/index.js +2 -2
  86. package/dist/modules/crm/index.js.map +1 -1
  87. package/dist/modules/crm/messages.d.ts +4 -1
  88. package/dist/modules/crm/messages.d.ts.map +1 -1
  89. package/dist/modules/crm/messages.js +6 -0
  90. package/dist/modules/crm/messages.js.map +1 -1
  91. package/dist/modules/crm/record-modal-labels.d.ts +9 -0
  92. package/dist/modules/crm/record-modal-labels.d.ts.map +1 -0
  93. package/dist/modules/crm/record-modal-labels.js +36 -0
  94. package/dist/modules/crm/record-modal-labels.js.map +1 -0
  95. package/dist/modules/crm/types.d.ts +5 -0
  96. package/dist/modules/crm/types.d.ts.map +1 -1
  97. package/dist/modules/crm/types.js +9 -0
  98. package/dist/modules/crm/types.js.map +1 -1
  99. package/dist/modules/crm_backend/client/crm-configuration-modal.d.mts +352 -0
  100. package/dist/modules/crm_backend/client/crm-configuration-modal.d.mts.map +1 -0
  101. package/dist/modules/crm_backend/client/crm-configuration-modal.mjs +2 -0
  102. package/dist/modules/crm_backend/client/crm-configuration-modal.mjs.map +7 -0
  103. package/dist/modules/crm_backend/index.d.ts.map +1 -1
  104. package/dist/modules/crm_backend/index.js +5 -1
  105. package/dist/modules/crm_backend/index.js.map +1 -1
  106. package/dist/modules/crm_backend/islands.d.ts +27 -0
  107. package/dist/modules/crm_backend/islands.d.ts.map +1 -1
  108. package/dist/modules/crm_backend/islands.js +22 -0
  109. package/dist/modules/crm_backend/islands.js.map +1 -1
  110. package/dist/modules/crm_backend/messages.d.ts +30 -0
  111. package/dist/modules/crm_backend/messages.d.ts.map +1 -1
  112. package/dist/modules/crm_backend/messages.js +30 -0
  113. package/dist/modules/crm_backend/messages.js.map +1 -1
  114. package/dist/modules/crm_backend/modal/configuration-modal-view.d.ts +55 -0
  115. package/dist/modules/crm_backend/modal/configuration-modal-view.d.ts.map +1 -0
  116. package/dist/modules/crm_backend/modal/configuration-modal-view.js +601 -0
  117. package/dist/modules/crm_backend/modal/configuration-modal-view.js.map +1 -0
  118. package/dist/modules/crm_backend/routes.d.ts.map +1 -1
  119. package/dist/modules/crm_backend/routes.js +69 -359
  120. package/dist/modules/crm_backend/routes.js.map +1 -1
  121. package/dist/modules/crm_backend/screens/configuration.d.ts +24 -32
  122. package/dist/modules/crm_backend/screens/configuration.d.ts.map +1 -1
  123. package/dist/modules/crm_backend/screens/configuration.js +183 -274
  124. package/dist/modules/crm_backend/screens/configuration.js.map +1 -1
  125. package/dist/modules/crm_backend/screens/index.d.ts +2 -2
  126. package/dist/modules/crm_backend/screens/index.d.ts.map +1 -1
  127. package/dist/modules/crm_backend/screens/index.js +1 -1
  128. package/dist/modules/crm_backend/screens/index.js.map +1 -1
  129. package/dist/modules/product/functions.d.ts +14 -1
  130. package/dist/modules/product/functions.d.ts.map +1 -1
  131. package/dist/modules/product/functions.js +36 -31
  132. package/dist/modules/product/functions.js.map +1 -1
  133. package/dist/modules/product/product-modal-context.d.ts +3 -0
  134. package/dist/modules/product/product-modal-context.d.ts.map +1 -0
  135. package/dist/modules/product/product-modal-context.js +171 -0
  136. package/dist/modules/product/product-modal-context.js.map +1 -0
  137. package/dist/modules/product_backend/client/product-modal.d.mts +143 -0
  138. package/dist/modules/product_backend/client/product-modal.d.mts.map +1 -0
  139. package/dist/modules/product_backend/client/product-modal.mjs +2 -0
  140. package/dist/modules/product_backend/client/product-modal.mjs.map +7 -0
  141. package/dist/modules/product_backend/index.d.ts.map +1 -1
  142. package/dist/modules/product_backend/index.js +16 -0
  143. package/dist/modules/product_backend/index.js.map +1 -1
  144. package/dist/modules/product_backend/islands.d.ts +1 -0
  145. package/dist/modules/product_backend/islands.d.ts.map +1 -1
  146. package/dist/modules/product_backend/islands.js +9 -0
  147. package/dist/modules/product_backend/islands.js.map +1 -1
  148. package/dist/modules/product_backend/modal/product-modal-view.d.ts +43 -0
  149. package/dist/modules/product_backend/modal/product-modal-view.d.ts.map +1 -0
  150. package/dist/modules/product_backend/modal/product-modal-view.js +602 -0
  151. package/dist/modules/product_backend/modal/product-modal-view.js.map +1 -0
  152. package/dist/modules/product_backend/routes.d.ts.map +1 -1
  153. package/dist/modules/product_backend/routes.js +44 -330
  154. package/dist/modules/product_backend/routes.js.map +1 -1
  155. package/dist/modules/product_backend/screens/detail.d.ts.map +1 -1
  156. package/dist/modules/product_backend/screens/detail.js +6 -108
  157. package/dist/modules/product_backend/screens/detail.js.map +1 -1
  158. package/dist/modules/product_backend/screens/index.d.ts +0 -1
  159. package/dist/modules/product_backend/screens/index.d.ts.map +1 -1
  160. package/dist/modules/product_backend/screens/index.js +0 -1
  161. package/dist/modules/product_backend/screens/index.js.map +1 -1
  162. package/dist/modules/product_backend/screens/list.d.ts +3 -1
  163. package/dist/modules/product_backend/screens/list.d.ts.map +1 -1
  164. package/dist/modules/product_backend/screens/list.js +6 -5
  165. package/dist/modules/product_backend/screens/list.js.map +1 -1
  166. package/dist/modules/user/authorization.d.ts.map +1 -1
  167. package/dist/modules/user/authorization.js +114 -0
  168. package/dist/modules/user/authorization.js.map +1 -1
  169. package/dist/modules/user/functions.d.ts.map +1 -1
  170. package/dist/modules/user/functions.js +2 -0
  171. package/dist/modules/user/functions.js.map +1 -1
  172. package/dist/modules/user/user-modal-context.d.ts +3 -0
  173. package/dist/modules/user/user-modal-context.d.ts.map +1 -0
  174. package/dist/modules/user/user-modal-context.js +151 -0
  175. package/dist/modules/user/user-modal-context.js.map +1 -0
  176. package/dist/modules/user_backend/client/user-modal.d.mts +72 -0
  177. package/dist/modules/user_backend/client/user-modal.d.mts.map +1 -0
  178. package/dist/modules/user_backend/client/user-modal.mjs +2 -0
  179. package/dist/modules/user_backend/client/user-modal.mjs.map +7 -0
  180. package/dist/modules/user_backend/index.d.ts.map +1 -1
  181. package/dist/modules/user_backend/index.js +9 -0
  182. package/dist/modules/user_backend/index.js.map +1 -1
  183. package/dist/modules/user_backend/islands.d.ts +15 -0
  184. package/dist/modules/user_backend/islands.d.ts.map +1 -0
  185. package/dist/modules/user_backend/islands.js +14 -0
  186. package/dist/modules/user_backend/islands.js.map +1 -0
  187. package/dist/modules/user_backend/messages.d.ts +30 -0
  188. package/dist/modules/user_backend/messages.d.ts.map +1 -1
  189. package/dist/modules/user_backend/messages.js +30 -0
  190. package/dist/modules/user_backend/messages.js.map +1 -1
  191. package/dist/modules/user_backend/modal/user-modal-view.d.ts +26 -0
  192. package/dist/modules/user_backend/modal/user-modal-view.d.ts.map +1 -0
  193. package/dist/modules/user_backend/modal/user-modal-view.js +320 -0
  194. package/dist/modules/user_backend/modal/user-modal-view.js.map +1 -0
  195. package/dist/modules/user_backend/routes.d.ts.map +1 -1
  196. package/dist/modules/user_backend/routes.js +15 -3
  197. package/dist/modules/user_backend/routes.js.map +1 -1
  198. package/dist/permission-catalogue.d.ts.map +1 -1
  199. package/dist/permission-catalogue.js +8 -0
  200. package/dist/permission-catalogue.js.map +1 -1
  201. package/dist/scaffold/index.js +1 -1
  202. package/dist/ui/client/record-modal-form.d.ts +67 -0
  203. package/dist/ui/client/record-modal-form.d.ts.map +1 -0
  204. package/dist/ui/client/record-modal-form.js +45 -0
  205. package/dist/ui/client/record-modal-form.js.map +1 -0
  206. package/dist/ui/client/record-modal.d.ts +97 -6
  207. package/dist/ui/client/record-modal.d.ts.map +1 -1
  208. package/dist/ui/client/record-modal.js +330 -64
  209. package/dist/ui/client/record-modal.js.map +1 -1
  210. package/dist/ui/index.d.ts +2 -1
  211. package/dist/ui/index.d.ts.map +1 -1
  212. package/dist/ui/index.js +2 -1
  213. package/dist/ui/index.js.map +1 -1
  214. package/dist/ui/nav.d.ts +7 -0
  215. package/dist/ui/nav.d.ts.map +1 -1
  216. package/dist/ui/nav.js +1 -1
  217. package/dist/ui/nav.js.map +1 -1
  218. package/dist/ui/record-modal.d.ts +15 -0
  219. package/dist/ui/record-modal.d.ts.map +1 -1
  220. package/dist/ui/record-modal.js +12 -0
  221. package/dist/ui/record-modal.js.map +1 -1
  222. package/dist/ui/table.d.ts.map +1 -1
  223. package/dist/ui/table.js +1 -1
  224. package/dist/ui/table.js.map +1 -1
  225. package/package.json +4 -4
  226. package/dist/modules/crm/functions.d.ts +0 -10
  227. package/dist/modules/crm/functions.d.ts.map +0 -1
  228. package/dist/modules/crm/functions.js +0 -1394
  229. package/dist/modules/crm/functions.js.map +0 -1
  230. package/dist/modules/product_backend/screens/create.d.ts +0 -16
  231. package/dist/modules/product_backend/screens/create.d.ts.map +0 -1
  232. package/dist/modules/product_backend/screens/create.js +0 -86
  233. package/dist/modules/product_backend/screens/create.js.map +0 -1
@@ -1,1394 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { asc, defineFn, defineFormSchema, deleteFrom, eq, from, inArray, isNull } from '@ketvietlab/ketjs';
3
- import { activeStage, actorRequired, addCaseMessage, addTimeline, applyCasePlan, assignCase, cancelCaseActivity, canEditCase, caseDetail, closedAtFor, duplicateCases, commandKey, completeCaseActivity, ensureCrmDefaults, firstStage, gamificationProfile, invalid, issue, groupCases, listCases, moveCase, n, normalized, now, refreshCaseScore, pipelineSummary, reassignCase, saveCase, scheduleCaseActivity, serializeCaseList, stageKinds, visibleCases, } from './operations.js';
4
- import { ASSIGNMENT_MODES, CASE_KINDS, TERMINAL_STATES } from './types.js';
5
- const caseReadEffects = [
6
- 'read:crm.Case',
7
- 'read:crm.AccessGrant',
8
- 'read:crm.Stage',
9
- 'read:crm.Team',
10
- 'read:crm.TeamMember',
11
- 'read:crm.SalesDetail',
12
- 'read:crm.CaseTag',
13
- 'read:crm.Tag',
14
- 'read:crm.TimelineEntry',
15
- 'read:crm.Message',
16
- 'read:crm.ActivityLink',
17
- 'read:crm.CalendarLink',
18
- 'read:user.User',
19
- 'read:partner.Partner',
20
- 'read:activity.Activity',
21
- 'read:calendar.Event',
22
- 'read:storage.Attachment',
23
- ];
24
- const defaultEffects = [
25
- 'read:crm.Team',
26
- 'write:crm.Team',
27
- 'read:crm.Stage',
28
- 'write:crm.Stage',
29
- 'read:activity.Type',
30
- 'write:activity.Type',
31
- ];
32
- /** Shape rules shared by the long CRM create form and its backend route. */
33
- export const caseFormSchema = (requirements = {}) => defineFormSchema({
34
- fields: {
35
- name: { type: 'text', required: true, trim: true },
36
- kind: { type: 'text', required: true, oneOf: ['lead', 'opportunity'] },
37
- partnerId: { type: 'text', required: requirements.partner === true, trim: true },
38
- description: { type: 'text', required: requirements.need === true, trim: true },
39
- utmSource: { type: 'text', trim: true },
40
- priority: { type: 'text', oneOf: ['0', '1', '2', '3'] },
41
- expectedRevenue: { type: 'decimal', min: 0 },
42
- probability: { type: 'decimal', min: 0, max: 100 },
43
- expectedClosing: { type: 'date' },
44
- },
45
- unknown: 'drop',
46
- });
47
- export const caseWriteEffects = [
48
- ...caseReadEffects,
49
- ...defaultEffects,
50
- 'write:crm.Case',
51
- 'write:crm.SalesDetail',
52
- 'write:crm.CaseTag',
53
- 'write:crm.TimelineEntry',
54
- 'read:mail.Thread',
55
- 'write:mail.Thread',
56
- 'enqueue:crm.score',
57
- ];
58
- const activityEffects = [
59
- ...caseReadEffects,
60
- 'read:activity.Type',
61
- 'read:activity.Plan',
62
- 'read:activity.PlanStep',
63
- 'read:activity.Attachment',
64
- 'write:activity.Activity',
65
- 'write:activity.Attachment',
66
- 'write:crm.ActivityLink',
67
- 'write:crm.TimelineEntry',
68
- 'read:mail.Thread',
69
- 'read:mail.Message',
70
- 'read:mail.Follower',
71
- 'read:mail.FollowerSubtype',
72
- 'read:mail.Subtype',
73
- 'write:mail.Message',
74
- 'write:mail.MessageAttachment',
75
- 'write:mail.Mention',
76
- 'write:mail.TrackingValue',
77
- 'write:mail.Notification',
78
- 'read:storage.Attachment',
79
- ];
80
- const command = (ctx, key) => {
81
- if (!actorRequired(ctx))
82
- return invalid(issue('actor', 'crm.error.actorRequired'));
83
- if (!commandKey(key))
84
- return invalid(issue('idempotencyKey', 'crm.error.idempotencyRequired'));
85
- return null;
86
- };
87
- const isSuperuser = async (ctx) => {
88
- if (!ctx.actor)
89
- return false;
90
- const actor = (await ctx.db.select('user.User', { id: ctx.actor, active: true }))[0];
91
- return actor?.superuser === true;
92
- };
93
- const accessScopes = new Set(['none', 'self', 'team', 'company']);
94
- const ensureCase = async (ctx, id) => (await ctx.db.select('crm.Case', { id }))[0] ?? null;
95
- /**
96
- * One shape for the small configuration lists the pickers read: active first,
97
- * filtered by name, capped so a keystroke never pulls a whole table.
98
- */
99
- const optionRows = async (ctx, model, args, order) => {
100
- const rows = await ctx.db.select(model, args.includeArchived === true ? {} : { active: true });
101
- const needle = normalized(args.search);
102
- return rows
103
- .filter((row) => !needle || normalized(row.name).includes(needle) || normalized(row.code).includes(needle))
104
- .sort((a, b) => (order ? order(a, b) : 0) ||
105
- String(a.name ?? '').localeCompare(String(b.name ?? '')) ||
106
- String(a.id).localeCompare(String(b.id)))
107
- .slice(0, Math.max(1, Math.min(200, n(args.limit ?? 80))));
108
- };
109
- async function moveToTerminal(ctx, input) {
110
- const error = command(ctx, input.idempotencyKey);
111
- if (error)
112
- return error;
113
- return ctx.tx(async (tx) => {
114
- const held = await ensureCase(tx, input.id);
115
- if (!held || !(await canEditCase(tx, held)))
116
- return invalid(issue('id', 'crm.error.notFound'));
117
- if (held.kind !== 'opportunity')
118
- return invalid(issue('kind', 'crm.error.leadConversion'));
119
- const stages = (await tx.db.select('crm.Stage', { active: true })).filter((stage) => stage.terminalState === input.terminal &&
120
- Array.isArray(stage.allowedKinds) &&
121
- stage.allowedKinds.map(String).includes(String(held.kind)));
122
- const stage = stages.sort((a, b) => n(a.sequence) - n(b.sequence) || String(a.id).localeCompare(String(b.id)))[0];
123
- if (!stage)
124
- return invalid(issue('stageId', 'crm.error.invalidStage'));
125
- if (held.kind === 'opportunity' && input.terminal === 'lost') {
126
- const detail = (await tx.db.select('crm.SalesDetail', { caseId: input.id }))[0];
127
- if (detail)
128
- await tx.db.update('crm.SalesDetail', { id: detail.id }, { lostReason: input.lostReason ?? null });
129
- }
130
- const timestamp = now();
131
- const changed = await tx.db.compareAndSet('crm.Case', { id: input.id }, { version: input.expectedVersion }, {
132
- stageId: stage.id,
133
- terminalState: input.terminal,
134
- active: true,
135
- version: n(held.version) + 1,
136
- updatedAt: timestamp,
137
- closedAt: closedAtFor(held, input.terminal, timestamp),
138
- });
139
- if (!('dryRun' in changed) && !changed.matched)
140
- return invalid(issue('version', 'crm.error.stageConflict', { current: held.version }));
141
- const event = input.terminal === 'won' ? 'won' : 'lost';
142
- await addTimeline(tx, {
143
- id: `timeline:${input.id}:${event}:${input.idempotencyKey}`,
144
- caseId: input.id,
145
- eventType: event,
146
- body: `crm.timeline.${event}`,
147
- metadata: input.closeReason ? { reason: input.closeReason } : undefined,
148
- customerVisible: false,
149
- occurredAt: timestamp,
150
- });
151
- if (held.assigneeUserId)
152
- await tx.jobs.enqueue('crm.gamification', { userId: held.assigneeUserId }, { uniqueKey: `crm.gamification:${String(held.assigneeUserId)}` });
153
- return { ok: true, id: input.id, version: n(held.version) + 1, terminalState: input.terminal };
154
- });
155
- }
156
- const saveConfiguration = (model, prepare, validate, additionalEffects = []) => defineFn({
157
- input: { values: 'json', idempotencyKey: 'text' },
158
- output: { ok: 'bool', id: 'id?', version: 'int?', errors: 'json?' },
159
- effects: [`read:${model}`, `write:${model}`, ...additionalEffects],
160
- idempotent: true,
161
- agent: true,
162
- handler: async (ctx, args) => {
163
- const error = command(ctx, args.idempotencyKey);
164
- if (error)
165
- return error;
166
- if (!args.values || typeof args.values !== 'object')
167
- return invalid(issue('values', 'crm.error.required'));
168
- const values = args.values;
169
- const id = String(values.id ?? '');
170
- if (!id)
171
- return invalid(issue('id', 'crm.error.required'));
172
- const existing = (await ctx.db.select(model, { id }))[0];
173
- const validation = await validate?.(ctx, values, existing);
174
- if (validation)
175
- return validation;
176
- const expectedVersion = values.expectedVersion == null ? undefined : n(values.expectedVersion);
177
- if (existing && expectedVersion != null && n(existing.version) !== expectedVersion)
178
- return invalid(issue('version', 'crm.error.stageConflict', { current: n(existing.version) }));
179
- const version = n(existing?.version) + 1;
180
- const row = { ...prepare(values, existing), version };
181
- if (existing?.version != null) {
182
- const changed = await ctx.db.compareAndSet(model, { id }, { version: n(existing.version) }, row);
183
- if (!('dryRun' in changed) && !changed.matched)
184
- return invalid(issue('version', 'crm.error.stageConflict', { current: n(existing.version) }));
185
- }
186
- else if (existing)
187
- await ctx.db.update(model, { id }, row);
188
- else
189
- await ctx.db.insert(model, { id, ...row });
190
- return { ok: true, id, version };
191
- },
192
- });
193
- export const functions = {
194
- 'bootstrap.defaults': defineFn({
195
- input: { idempotencyKey: 'text' },
196
- output: { ok: 'bool' },
197
- effects: [...defaultEffects],
198
- idempotent: true,
199
- agent: true,
200
- handler: async (ctx, args) => {
201
- const error = command(ctx, args.idempotencyKey);
202
- if (error)
203
- return error;
204
- await ensureCrmDefaults(ctx);
205
- return { ok: true };
206
- },
207
- }),
208
- 'access.get': defineFn({
209
- input: { userId: 'id?' },
210
- output: { grant: 'json?' },
211
- effects: ['read:crm.AccessGrant', 'read:user.User'],
212
- agent: true,
213
- handler: async (ctx, args) => {
214
- if (!ctx.actor)
215
- return { grant: null };
216
- const requested = String(args.userId ?? ctx.actor);
217
- if (requested !== ctx.actor && !(await isSuperuser(ctx)))
218
- return { grant: null };
219
- const grant = (await ctx.db.select('crm.AccessGrant', { userId: requested }))[0] ?? null;
220
- return { grant };
221
- },
222
- }),
223
- 'access.save': defineFn({
224
- input: {
225
- id: 'id',
226
- userId: 'id',
227
- viewScope: 'text',
228
- editScope: 'text',
229
- assignScope: 'text',
230
- active: 'bool?',
231
- expectedVersion: 'int?',
232
- idempotencyKey: 'text',
233
- },
234
- output: { ok: 'bool', id: 'id?', version: 'int?', errors: 'json?' },
235
- effects: ['read:crm.AccessGrant', 'write:crm.AccessGrant', 'read:user.User'],
236
- idempotent: true,
237
- agent: true,
238
- handler: async (ctx, args) => {
239
- const error = command(ctx, args.idempotencyKey);
240
- if (error)
241
- return error;
242
- if (!(await isSuperuser(ctx)))
243
- return invalid(issue('actor', 'crm.error.permission'));
244
- if (!accessScopes.has(String(args.viewScope)) ||
245
- !accessScopes.has(String(args.editScope)) ||
246
- !accessScopes.has(String(args.assignScope)))
247
- return invalid(issue('viewScope', 'crm.error.invalidAccessScope'));
248
- const user = (await ctx.db.select('user.User', { id: args.userId, active: true }))[0];
249
- if (!user)
250
- return invalid(issue('userId', 'crm.error.notFound'));
251
- const byUser = (await ctx.db.select('crm.AccessGrant', { userId: args.userId }))[0];
252
- const existing = (await ctx.db.select('crm.AccessGrant', { id: args.id }))[0];
253
- if (byUser && byUser.id !== args.id)
254
- return invalid(issue('userId', 'crm.error.duplicateName'));
255
- if (existing && existing.userId !== args.userId)
256
- return invalid(issue('userId', 'crm.error.permission'));
257
- const expected = args.expectedVersion == null ? n(existing?.version) : n(args.expectedVersion);
258
- const version = n(existing?.version) + 1;
259
- const values = {
260
- userId: args.userId,
261
- viewScope: args.viewScope,
262
- editScope: args.editScope,
263
- assignScope: args.assignScope,
264
- active: args.active ?? true,
265
- version,
266
- };
267
- if (existing) {
268
- const changed = await ctx.db.compareAndSet('crm.AccessGrant', { id: args.id }, { version: expected }, values);
269
- if (!('dryRun' in changed) && !changed.matched)
270
- return invalid(issue('version', 'crm.error.stageConflict', { current: existing.version }));
271
- }
272
- else
273
- await ctx.db.insert('crm.AccessGrant', { id: args.id, ...values });
274
- return { ok: true, id: args.id, version };
275
- },
276
- }),
277
- 'case.list': defineFn({
278
- input: {
279
- kind: 'text?',
280
- stageId: 'id?',
281
- teamId: 'id?',
282
- assigneeUserId: 'id?',
283
- /** Only the cases assigned to the caller. Resolved from the session, not the URL. */
284
- mine: 'bool?',
285
- terminalState: 'text?',
286
- search: 'text?',
287
- includeArchived: 'bool?',
288
- cursor: 'text?',
289
- limit: 'int?',
290
- listState: 'json?',
291
- path: 'json?',
292
- timezone: 'text?',
293
- },
294
- output: { rows: 'json', total: 'int', nextCursor: 'text?' },
295
- effects: [...caseReadEffects],
296
- agent: true,
297
- handler: (ctx, args) => listCases(ctx, args),
298
- }),
299
- 'pipeline.summary': defineFn({
300
- input: {
301
- kind: 'text?',
302
- teamId: 'id?',
303
- mine: 'bool?',
304
- search: 'text?',
305
- timezone: 'text?',
306
- },
307
- output: {
308
- stages: 'json',
309
- openCount: 'int',
310
- expectedRevenue: 'decimal',
311
- weightedRevenue: 'decimal',
312
- overdueActivityCount: 'int',
313
- partial: 'bool',
314
- },
315
- effects: [...caseReadEffects],
316
- agent: true,
317
- handler: (ctx, args) => pipelineSummary(ctx, args),
318
- }),
319
- overview: defineFn({
320
- input: { today: 'date' },
321
- output: {
322
- leadCount: 'int',
323
- opportunityCount: 'int',
324
- openOpportunityCount: 'int',
325
- overdueActivityCount: 'int',
326
- expectedRevenue: 'decimal',
327
- },
328
- effects: [...caseReadEffects],
329
- agent: true,
330
- handler: async (ctx, args) => {
331
- const C = ctx.table('crm.Case');
332
- const owned = await ctx.db.all(from(C).where(eq(C.active, true), inArray(C.kind, [...CASE_KINDS])));
333
- const visible = await visibleCases(ctx, owned);
334
- const rows = await serializeCaseList(ctx, visible);
335
- const openOpportunities = rows.filter((row) => row.kind === 'opportunity' && row.terminalState === 'open');
336
- const visibleIds = [...new Set(rows.map((row) => String(row.id)))];
337
- // Reading every link in the tenant to keep the handful that belong to
338
- // these cases is work the query can do instead.
339
- const L = ctx.table('crm.ActivityLink');
340
- const links = visibleIds.length ? await ctx.db.all(from(L).where(inArray(L.caseId, visibleIds))) : [];
341
- const activityIds = [...new Set(links.map((link) => String(link.activityId)))];
342
- const activities = activityIds.length
343
- ? await ctx.db.all(from(ctx.table('activity.Activity')).where(inArray(ctx.table('activity.Activity').id, activityIds)))
344
- : [];
345
- return {
346
- leadCount: rows.filter((row) => row.kind === 'lead').length,
347
- opportunityCount: rows.filter((row) => row.kind === 'opportunity').length,
348
- openOpportunityCount: openOpportunities.length,
349
- overdueActivityCount: activities.filter((activity) => activity.active !== false &&
350
- activity.doneAt == null &&
351
- activity.canceledAt == null &&
352
- String(activity.dueDate) < String(args.today)).length,
353
- expectedRevenue: String(openOpportunities.reduce((total, row) => total + n(row.expectedRevenue), 0)),
354
- };
355
- },
356
- }),
357
- 'case.count': defineFn({
358
- input: {
359
- kind: 'text?',
360
- stageId: 'id?',
361
- teamId: 'id?',
362
- assigneeUserId: 'id?',
363
- terminalState: 'text?',
364
- search: 'text?',
365
- includeArchived: 'bool?',
366
- listState: 'json?',
367
- timezone: 'text?',
368
- },
369
- output: { count: 'int' },
370
- effects: [...caseReadEffects],
371
- agent: true,
372
- handler: async (ctx, args) => ({ count: (await listCases(ctx, { ...args, limit: 1 })).total }),
373
- }),
374
- 'case.group': defineFn({
375
- input: {
376
- kind: 'text?',
377
- listState: 'json',
378
- path: 'json?',
379
- timezone: 'text?',
380
- limit: 'int?',
381
- offset: 'int?',
382
- },
383
- effects: [...caseReadEffects],
384
- agent: true,
385
- handler: (ctx, args) => groupCases(ctx, args),
386
- }),
387
- 'case.get': defineFn({
388
- input: { id: 'id' },
389
- effects: [...caseReadEffects],
390
- agent: true,
391
- handler: (ctx, args) => caseDetail(ctx, String(args.id)),
392
- }),
393
- 'case.save': defineFn({
394
- input: {
395
- id: 'id',
396
- kind: 'text',
397
- name: 'text',
398
- partnerId: 'id?',
399
- contactName: 'text?',
400
- email: 'text?',
401
- phone: 'text?',
402
- teamId: 'id?',
403
- assigneeUserId: 'id?',
404
- stageId: 'id?',
405
- priority: 'text?',
406
- description: 'text?',
407
- utmSource: 'text?',
408
- utmMedium: 'text?',
409
- utmCampaign: 'text?',
410
- expectedRevenue: 'decimal?',
411
- recurringRevenue: 'decimal?',
412
- probability: 'decimal?',
413
- expectedClosing: 'date?',
414
- forecastCategory: 'text?',
415
- tagIds: 'json?',
416
- expectedVersion: 'int?',
417
- idempotencyKey: 'text',
418
- },
419
- output: { ok: 'bool', id: 'id?', version: 'int?', errors: 'json?' },
420
- effects: [...caseWriteEffects],
421
- idempotent: true,
422
- agent: true,
423
- handler: (ctx, args) => saveCase(ctx, {
424
- ...args,
425
- id: String(args.id),
426
- kind: String(args.kind),
427
- name: String(args.name),
428
- idempotencyKey: String(args.idempotencyKey),
429
- tagIds: Array.isArray(args.tagIds) ? args.tagIds.map(String) : undefined,
430
- }),
431
- }),
432
- 'case.move': defineFn({
433
- input: { id: 'id', stageId: 'id', expectedVersion: 'int', idempotencyKey: 'text' },
434
- output: { ok: 'bool', id: 'id?', version: 'int?', terminalState: 'text?', errors: 'json?' },
435
- effects: [
436
- 'read:crm.Case',
437
- 'write:crm.Case',
438
- 'read:crm.AccessGrant',
439
- 'read:crm.Team',
440
- 'read:crm.Stage',
441
- 'read:crm.TeamMember',
442
- 'read:user.User',
443
- 'write:crm.TimelineEntry',
444
- 'enqueue:crm.gamification',
445
- ],
446
- idempotent: true,
447
- agent: true,
448
- handler: (ctx, args) => moveCase(ctx, {
449
- id: String(args.id),
450
- stageId: String(args.stageId),
451
- expectedVersion: Number(args.expectedVersion),
452
- idempotencyKey: String(args.idempotencyKey),
453
- }),
454
- }),
455
- 'case.assign': defineFn({
456
- input: {
457
- id: 'id',
458
- teamId: 'id?',
459
- assigneeUserId: 'id?',
460
- expectedVersion: 'int?',
461
- force: 'bool?',
462
- idempotencyKey: 'text',
463
- },
464
- output: {
465
- ok: 'bool',
466
- id: 'id?',
467
- teamId: 'id?',
468
- assigneeUserId: 'id?',
469
- version: 'int?',
470
- errors: 'json?',
471
- },
472
- effects: [
473
- 'read:crm.Case',
474
- 'write:crm.Case',
475
- 'read:crm.AccessGrant',
476
- 'read:crm.Team',
477
- 'write:crm.Team',
478
- 'read:crm.TeamMember',
479
- 'write:crm.TeamMember',
480
- 'read:crm.AssignmentRule',
481
- 'read:user.User',
482
- 'write:crm.TimelineEntry',
483
- ],
484
- idempotent: true,
485
- agent: true,
486
- handler: (ctx, args) => assignCase(ctx, args),
487
- }),
488
- 'case.reassign': defineFn({
489
- input: {
490
- id: 'id',
491
- teamId: 'id',
492
- assigneeUserId: 'id?',
493
- reasonCode: 'text',
494
- reasonNote: 'text?',
495
- expectedVersion: 'int',
496
- idempotencyKey: 'text',
497
- },
498
- output: {
499
- ok: 'bool',
500
- id: 'id?',
501
- teamId: 'id?',
502
- assigneeUserId: 'id?',
503
- version: 'int?',
504
- errors: 'json?',
505
- },
506
- effects: [
507
- 'read:crm.Case',
508
- 'write:crm.Case',
509
- 'read:crm.AccessGrant',
510
- 'read:crm.Team',
511
- 'read:crm.TeamMember',
512
- 'read:user.User',
513
- 'read:crm.TimelineEntry',
514
- 'write:crm.TimelineEntry',
515
- ],
516
- idempotent: true,
517
- agent: true,
518
- handler: (ctx, args) => reassignCase(ctx, args),
519
- }),
520
- 'case.convertLead': defineFn({
521
- input: {
522
- id: 'id',
523
- expectedVersion: 'int',
524
- stageId: 'id?',
525
- expectedRevenue: 'decimal?',
526
- expectedClosing: 'date?',
527
- idempotencyKey: 'text',
528
- },
529
- output: { ok: 'bool', id: 'id?', version: 'int?', errors: 'json?' },
530
- effects: [
531
- 'read:crm.Case',
532
- 'write:crm.Case',
533
- 'read:crm.AccessGrant',
534
- 'read:crm.Team',
535
- 'read:crm.Stage',
536
- 'read:crm.SalesDetail',
537
- 'write:crm.SalesDetail',
538
- 'write:crm.TimelineEntry',
539
- 'read:user.User',
540
- 'read:crm.TeamMember',
541
- ],
542
- idempotent: true,
543
- agent: true,
544
- handler: async (ctx, args) => {
545
- const error = command(ctx, args.idempotencyKey);
546
- if (error)
547
- return error;
548
- return ctx.tx(async (tx) => {
549
- const held = await ensureCase(tx, args.id);
550
- if (!held || !(await canEditCase(tx, held)))
551
- return invalid(issue('id', 'crm.error.notFound'));
552
- if (held.kind !== 'lead') {
553
- if (held.kind === 'opportunity')
554
- return { ok: true, id: held.id, version: held.version };
555
- return invalid(issue('kind', 'crm.error.leadConversion'));
556
- }
557
- const stage = args.stageId
558
- ? await activeStage(tx, args.stageId, 'opportunity')
559
- : await firstStage(tx, 'opportunity');
560
- if (!stage)
561
- return invalid(issue('stageId', 'crm.error.invalidStage'));
562
- const timestamp = now();
563
- const changed = await tx.db.compareAndSet('crm.Case', { id: args.id }, { version: args.expectedVersion }, {
564
- kind: 'opportunity',
565
- stageId: stage.id,
566
- terminalState: stage.terminalState,
567
- convertedAt: timestamp,
568
- version: n(held.version) + 1,
569
- updatedAt: timestamp,
570
- });
571
- if (!('dryRun' in changed) && !changed.matched)
572
- return invalid(issue('version', 'crm.error.stageConflict', { current: held.version }));
573
- const detail = (await tx.db.select('crm.SalesDetail', { caseId: args.id }))[0];
574
- const salesValues = {
575
- sourceLeadId: args.id,
576
- ...(args.expectedRevenue === undefined ? {} : { expectedRevenue: String(args.expectedRevenue) }),
577
- ...(args.expectedClosing === undefined ? {} : { expectedClosing: args.expectedClosing }),
578
- };
579
- if (detail)
580
- await tx.db.update('crm.SalesDetail', { id: detail.id }, salesValues);
581
- else
582
- await tx.db.insert('crm.SalesDetail', {
583
- id: `sales:${String(args.id)}`,
584
- caseId: args.id,
585
- expectedRevenue: String(args.expectedRevenue ?? '0'),
586
- recurringRevenue: '0',
587
- probability: '0',
588
- expectedClosing: args.expectedClosing ?? null,
589
- forecastCategory: 'pipeline',
590
- lostReason: null,
591
- ...salesValues,
592
- });
593
- await addTimeline(tx, {
594
- id: `timeline:${String(args.id)}:convert:${String(args.idempotencyKey)}`,
595
- caseId: String(args.id),
596
- eventType: 'converted',
597
- body: 'crm.timeline.converted',
598
- });
599
- return { ok: true, id: args.id, version: n(held.version) + 1 };
600
- });
601
- },
602
- }),
603
- 'case.merge': defineFn({
604
- input: { targetId: 'id', sourceId: 'id', expectedTargetVersion: 'int', idempotencyKey: 'text' },
605
- output: { ok: 'bool', id: 'id?', version: 'int?', errors: 'json?' },
606
- effects: [
607
- 'read:crm.Case',
608
- 'write:crm.Case',
609
- 'read:crm.AccessGrant',
610
- 'read:crm.Team',
611
- 'read:crm.CaseTag',
612
- 'write:crm.CaseTag',
613
- 'read:crm.Message',
614
- 'write:crm.Message',
615
- 'read:crm.ActivityLink',
616
- 'write:crm.ActivityLink',
617
- 'read:crm.CalendarLink',
618
- 'write:crm.CalendarLink',
619
- 'read:crm.SalesDetail',
620
- 'write:crm.SalesDetail',
621
- 'read:crm.TimelineEntry',
622
- 'write:crm.TimelineEntry',
623
- 'read:crm.TeamMember',
624
- 'read:user.User',
625
- ],
626
- idempotent: true,
627
- agent: true,
628
- handler: async (ctx, args) => {
629
- const error = command(ctx, args.idempotencyKey);
630
- if (error)
631
- return error;
632
- if (args.targetId === args.sourceId)
633
- return invalid(issue('sourceId', 'crm.error.required'));
634
- return ctx.tx(async (tx) => {
635
- const [target, source] = await Promise.all([
636
- ensureCase(tx, args.targetId),
637
- ensureCase(tx, args.sourceId),
638
- ]);
639
- if (!target || !source)
640
- return invalid(issue('id', 'crm.error.notFound'));
641
- if (!(await canEditCase(tx, target)))
642
- return invalid(issue('targetId', 'crm.error.notFound'));
643
- if (!(await canEditCase(tx, source)))
644
- return invalid(issue('sourceId', 'crm.error.notFound'));
645
- if (target.kind !== source.kind)
646
- return invalid(issue('sourceId', 'crm.error.invalidKind'));
647
- // Merging a record that is already folded into a third one would strand
648
- // the history it carries, and merging into an archived target hides the
649
- // result the moment it is written.
650
- if (source.mergedIntoId || source.active === false)
651
- return invalid(issue('sourceId', 'crm.error.alreadyMerged'));
652
- if (target.mergedIntoId || target.active === false)
653
- return invalid(issue('targetId', 'crm.error.alreadyMerged'));
654
- const timestamp = now();
655
- const changed = await tx.db.compareAndSet('crm.Case', { id: args.targetId }, { version: args.expectedTargetVersion }, { version: n(target.version) + 1, updatedAt: timestamp });
656
- if (!('dryRun' in changed) && !changed.matched)
657
- return invalid(issue('version', 'crm.error.stageConflict', { current: target.version }));
658
- for (const join of await tx.db.select('crm.CaseTag', { caseId: args.sourceId }))
659
- await tx.db.insertIfAbsent('crm.CaseTag', {
660
- id: `${String(args.targetId)}:${String(join.tagId)}`,
661
- caseId: args.targetId,
662
- tagId: join.tagId,
663
- });
664
- // Everything the source carries moves with it. A merge that left the
665
- // timeline and the meetings behind buried them on an archived record
666
- // nobody opens again.
667
- for (const model of ['crm.Message', 'crm.ActivityLink', 'crm.TimelineEntry'])
668
- for (const row of await tx.db.select(model, { caseId: args.sourceId }))
669
- await tx.db.update(model, { id: row.id }, { caseId: args.targetId });
670
- for (const link of await tx.db.select('crm.CalendarLink', { caseId: args.sourceId })) {
671
- const held = (await tx.db.select('crm.CalendarLink', { caseId: args.targetId, eventId: link.eventId }))[0];
672
- if (held)
673
- await tx.db.del(deleteFrom(tx.table('crm.CalendarLink')).where(eq(tx.table('crm.CalendarLink').id, link.id)));
674
- else
675
- await tx.db.update('crm.CalendarLink', { id: link.id }, { caseId: args.targetId });
676
- }
677
- const [targetDetail, sourceDetail] = await Promise.all([
678
- tx.db.select('crm.SalesDetail', { caseId: args.targetId }),
679
- tx.db.select('crm.SalesDetail', { caseId: args.sourceId }),
680
- ]);
681
- const kept = targetDetail[0];
682
- const dropped = sourceDetail[0];
683
- // The target's own figures win; the source only fills a blank, which is
684
- // what makes merging a bare duplicate into a qualified record safe.
685
- if (kept && dropped) {
686
- const carried = {};
687
- if (!Number(kept.expectedRevenue) && Number(dropped.expectedRevenue))
688
- carried.expectedRevenue = dropped.expectedRevenue;
689
- if (!Number(kept.recurringRevenue) && Number(dropped.recurringRevenue))
690
- carried.recurringRevenue = dropped.recurringRevenue;
691
- if (!Number(kept.probability) && Number(dropped.probability))
692
- carried.probability = dropped.probability;
693
- if (!kept.expectedClosing && dropped.expectedClosing)
694
- carried.expectedClosing = dropped.expectedClosing;
695
- if (Object.keys(carried).length)
696
- await tx.db.update('crm.SalesDetail', { id: kept.id }, carried);
697
- }
698
- await tx.db.update('crm.Case', { id: args.sourceId }, {
699
- active: false,
700
- terminalState: source.terminalState,
701
- mergedIntoId: args.targetId,
702
- closedAt: source.closedAt ?? timestamp,
703
- version: n(source.version) + 1,
704
- updatedAt: timestamp,
705
- });
706
- await addTimeline(tx, {
707
- id: `timeline:${String(args.targetId)}:merge:${String(args.idempotencyKey)}`,
708
- caseId: String(args.targetId),
709
- eventType: 'merged',
710
- body: 'crm.timeline.merged',
711
- metadata: { sourceId: args.sourceId, sourceName: source.name },
712
- occurredAt: timestamp,
713
- });
714
- return { ok: true, id: args.targetId, version: n(target.version) + 1 };
715
- });
716
- },
717
- }),
718
- 'case.markWon': defineFn({
719
- input: { id: 'id', expectedVersion: 'int', closeReason: 'text?', idempotencyKey: 'text' },
720
- output: { ok: 'bool', id: 'id?', version: 'int?', terminalState: 'text?', errors: 'json?' },
721
- effects: [
722
- 'read:crm.Case',
723
- 'write:crm.Case',
724
- 'read:crm.AccessGrant',
725
- 'read:crm.Team',
726
- 'read:crm.Stage',
727
- 'write:crm.TimelineEntry',
728
- 'read:crm.TeamMember',
729
- 'read:user.User',
730
- 'enqueue:crm.gamification',
731
- ],
732
- idempotent: true,
733
- agent: true,
734
- handler: (ctx, args) => moveToTerminal(ctx, {
735
- id: String(args.id),
736
- expectedVersion: Number(args.expectedVersion),
737
- idempotencyKey: String(args.idempotencyKey),
738
- terminal: 'won',
739
- closeReason: args.closeReason ? String(args.closeReason) : undefined,
740
- }),
741
- }),
742
- 'case.markLost': defineFn({
743
- input: {
744
- id: 'id',
745
- expectedVersion: 'int',
746
- lostReason: 'text',
747
- closeReason: 'text?',
748
- idempotencyKey: 'text',
749
- },
750
- output: { ok: 'bool', id: 'id?', version: 'int?', terminalState: 'text?', errors: 'json?' },
751
- effects: [
752
- 'read:crm.Case',
753
- 'write:crm.Case',
754
- 'read:crm.AccessGrant',
755
- 'read:crm.Team',
756
- 'read:crm.Stage',
757
- 'read:crm.SalesDetail',
758
- 'write:crm.SalesDetail',
759
- 'write:crm.TimelineEntry',
760
- 'read:crm.TeamMember',
761
- 'read:user.User',
762
- 'enqueue:crm.gamification',
763
- ],
764
- idempotent: true,
765
- agent: true,
766
- handler: (ctx, args) => moveToTerminal(ctx, {
767
- id: String(args.id),
768
- expectedVersion: Number(args.expectedVersion),
769
- lostReason: String(args.lostReason),
770
- closeReason: args.closeReason ? String(args.closeReason) : undefined,
771
- idempotencyKey: String(args.idempotencyKey),
772
- terminal: 'lost',
773
- }),
774
- }),
775
- 'case.detectDuplicates': defineFn({
776
- input: { id: 'id?', email: 'text?', phone: 'text?', name: 'text?', limit: 'int?' },
777
- output: { rows: 'json' },
778
- effects: [...caseReadEffects],
779
- agent: true,
780
- handler: async (ctx, args) => ({
781
- rows: await duplicateCases(ctx, args, n(args.limit ?? 20) || 20),
782
- }),
783
- }),
784
- 'case.addMessage': defineFn({
785
- input: { id: 'id', caseId: 'id', body: 'text', visibility: 'text', idempotencyKey: 'text' },
786
- output: { ok: 'bool', id: 'id?', errors: 'json?' },
787
- effects: [
788
- 'read:crm.Case',
789
- 'read:crm.Message',
790
- 'write:crm.Message',
791
- 'write:crm.TimelineEntry',
792
- 'read:crm.TeamMember',
793
- 'read:user.User',
794
- ],
795
- idempotent: true,
796
- agent: true,
797
- handler: async (ctx, args) => {
798
- const error = command(ctx, args.idempotencyKey);
799
- if (error)
800
- return error;
801
- return addCaseMessage(ctx, {
802
- id: String(args.id),
803
- caseId: String(args.caseId),
804
- body: String(args.body),
805
- visibility: String(args.visibility),
806
- });
807
- },
808
- }),
809
- 'case.refreshScore': defineFn({
810
- input: { id: 'id', idempotencyKey: 'text' },
811
- output: { ok: 'bool', id: 'id?', score: 'decimal?', reasons: 'json?', errors: 'json?' },
812
- effects: [
813
- 'read:crm.Case',
814
- 'write:crm.Case',
815
- 'read:crm.SalesDetail',
816
- 'read:crm.ScoreRule',
817
- 'read:crm.ScoreHistory',
818
- 'write:crm.ScoreHistory',
819
- 'read:crm.TeamMember',
820
- 'read:user.User',
821
- ],
822
- idempotent: true,
823
- agent: true,
824
- handler: async (ctx, args) => {
825
- const error = command(ctx, args.idempotencyKey);
826
- return error ?? refreshCaseScore(ctx, String(args.id), String(args.idempotencyKey));
827
- },
828
- }),
829
- /**
830
- * The CRM's own activity list.
831
- *
832
- * The planner used to read `activity.listMy`, which answers with every
833
- * activity the user owns anywhere in the suite — a stock transfer, a purchase
834
- * order, an invoice — and carries no way back to the record it belongs to. So
835
- * the CRM planner showed work from other apps and, for its own rows, showed a
836
- * summary the user could not navigate from. This one is scoped to cases the
837
- * actor may see and names the case on every row.
838
- */
839
- 'activity.listMine': defineFn({
840
- input: { today: 'date?', includeDone: 'bool?', mine: 'bool?', limit: 'int?' },
841
- output: {
842
- id: 'id',
843
- summary: 'text',
844
- dueDate: 'date',
845
- doneAt: 'datetime?',
846
- canceledAt: 'datetime?',
847
- assigneeUserId: 'id?',
848
- caseId: 'id',
849
- caseName: 'text',
850
- state: 'text',
851
- },
852
- effects: [
853
- 'read:crm.ActivityLink',
854
- 'read:crm.Case',
855
- 'read:crm.TeamMember',
856
- 'read:activity.Activity',
857
- 'read:user.User',
858
- ],
859
- agent: true,
860
- handler: async (ctx, args) => {
861
- const links = await ctx.db.select('crm.ActivityLink');
862
- if (!links.length)
863
- return [];
864
- const A = ctx.table('activity.Activity');
865
- let query = from(A).where(inArray(A.id, links.map((link) => link.activityId)));
866
- if (args.mine !== false && ctx.actor)
867
- query = query.where(eq(A.assigneeUserId, ctx.actor));
868
- if (args.includeDone !== true)
869
- query = query.where(eq(A.active, true), isNull(A.doneAt));
870
- const activities = await ctx.db.all(query.orderBy(asc(A.dueDate), asc(A.id)));
871
- if (!activities.length)
872
- return [];
873
- const caseIds = [...new Set(links.map((link) => String(link.caseId)))];
874
- const C = ctx.table('crm.Case');
875
- const cases = await ctx.db.all(from(C).where(inArray(C.id, caseIds)));
876
- // The same audience filter every other CRM read uses, so the planner
877
- // cannot become a way to see cases the list screen hides.
878
- const visible = new Map((await visibleCases(ctx, cases)).map((row) => [String(row.id), row]));
879
- const caseByActivity = new Map(links.map((link) => [String(link.activityId), String(link.caseId)]));
880
- // `today` dates the row rather than filtering it, which is how
881
- // `activity.listMy` reads it too: a planner that hid tomorrow's calls
882
- // would not be a planner.
883
- const today = String(args.today ?? '');
884
- const stateOf = (activity) => activity.doneAt
885
- ? 'done'
886
- : activity.canceledAt
887
- ? 'cancelled'
888
- : !today
889
- ? 'planned'
890
- : String(activity.dueDate) < today
891
- ? 'overdue'
892
- : String(activity.dueDate) === today
893
- ? 'today'
894
- : 'planned';
895
- return activities
896
- .flatMap((activity) => {
897
- const caseId = caseByActivity.get(String(activity.id));
898
- const held = caseId ? visible.get(caseId) : undefined;
899
- return held ? [{ ...activity, caseId, caseName: held.name, state: stateOf(activity) }] : [];
900
- })
901
- .slice(0, Math.max(1, Math.min(200, n(args.limit ?? 100))));
902
- },
903
- }),
904
- 'activity.schedule': defineFn({
905
- input: {
906
- id: 'id',
907
- caseId: 'id',
908
- typeId: 'id?',
909
- assigneeUserId: 'id?',
910
- summary: 'text',
911
- note: 'text?',
912
- dueDate: 'date',
913
- idempotencyKey: 'text',
914
- },
915
- output: { ok: 'bool', id: 'id?', activity: 'json?', errors: 'json?' },
916
- effects: [...activityEffects],
917
- idempotent: true,
918
- agent: true,
919
- handler: (ctx, args) => scheduleCaseActivity(ctx, args),
920
- }),
921
- 'activity.complete': defineFn({
922
- input: { id: 'id', feedback: 'text?', completedDate: 'date', idempotencyKey: 'text' },
923
- output: { ok: 'bool', id: 'id?', activity: 'json?', nextActivity: 'json?', errors: 'json?' },
924
- effects: [...activityEffects],
925
- idempotent: true,
926
- agent: true,
927
- handler: (ctx, args) => completeCaseActivity(ctx, args),
928
- }),
929
- 'activity.cancel': defineFn({
930
- input: { id: 'id', feedback: 'text?', idempotencyKey: 'text' },
931
- output: { ok: 'bool', id: 'id?', activity: 'json?', errors: 'json?' },
932
- effects: [...activityEffects],
933
- idempotent: true,
934
- agent: true,
935
- handler: (ctx, args) => cancelCaseActivity(ctx, args),
936
- }),
937
- 'plan.apply': defineFn({
938
- input: { caseId: 'id', planId: 'id', anchorDate: 'date', idempotencyKey: 'text' },
939
- output: { ok: 'bool', id: 'id?', activities: 'json?', errors: 'json?' },
940
- effects: [...activityEffects],
941
- idempotent: true,
942
- agent: true,
943
- handler: (ctx, args) => applyCasePlan(ctx, args),
944
- }),
945
- 'calendar.list': defineFn({
946
- input: { caseId: 'id?', from: 'datetime?', to: 'datetime?', cursor: 'text?', limit: 'int?' },
947
- output: { events: 'json', total: 'int', nextCursor: 'text?' },
948
- effects: [
949
- 'read:crm.CalendarLink',
950
- 'read:crm.Case',
951
- 'read:calendar.Event',
952
- 'read:user.User',
953
- 'read:crm.TeamMember',
954
- ],
955
- handler: async (ctx, args) => {
956
- const links = args.caseId
957
- ? await ctx.db.select('crm.CalendarLink', { caseId: args.caseId })
958
- : await ctx.db.select('crm.CalendarLink');
959
- const caseIds = [...new Set(links.map((link) => String(link.caseId)))];
960
- const cases = caseIds.length
961
- ? await ctx.db.all(from(ctx.table('crm.Case')).where(inArray(ctx.table('crm.Case').id, caseIds)))
962
- : [];
963
- const visible = new Set((await serializeCaseList(ctx, cases)).map((row) => String(row.id)));
964
- const eventIds = links.filter((link) => visible.has(String(link.caseId))).map((link) => link.eventId);
965
- let events = eventIds.length
966
- ? await ctx.db.all(from(ctx.table('calendar.Event'))
967
- .where(inArray(ctx.table('calendar.Event').id, eventIds), eq(ctx.table('calendar.Event').active, true))
968
- .orderBy(asc(ctx.table('calendar.Event').startAt)))
969
- : [];
970
- if (args.from)
971
- events = events.filter((event) => new Date(String(event.startAt ?? event.startDate)).getTime() >=
972
- new Date(String(args.from)).getTime());
973
- if (args.to)
974
- events = events.filter((event) => new Date(String(event.startAt ?? event.startDate)).getTime() <=
975
- new Date(String(args.to)).getTime());
976
- const caseBy = new Map(cases.map((row) => [String(row.id), row]));
977
- const caseIdByEvent = new Map(links
978
- .filter((link) => visible.has(String(link.caseId)))
979
- .map((link) => [String(link.eventId), String(link.caseId)]));
980
- const enriched = events.map((event) => {
981
- const linkedCaseId = caseIdByEvent.get(String(event.id));
982
- return {
983
- ...event,
984
- caseId: linkedCaseId ?? null,
985
- caseName: linkedCaseId ? (caseBy.get(linkedCaseId)?.name ?? linkedCaseId) : null,
986
- };
987
- });
988
- const offset = Math.max(0, Number.parseInt(String(args.cursor ?? '0'), 10) || 0);
989
- const limit = Math.max(1, Math.min(500, n(args.limit ?? 100)));
990
- return {
991
- events: enriched.slice(offset, offset + limit),
992
- total: enriched.length,
993
- nextCursor: offset + limit < enriched.length ? String(offset + limit) : null,
994
- };
995
- },
996
- }),
997
- 'gamification.refresh': defineFn({
998
- input: { userId: 'id?', limit: 'int?', idempotencyKey: 'text' },
999
- output: { ok: 'bool', profiles: 'json?', errors: 'json?' },
1000
- effects: [
1001
- 'read:user.User',
1002
- 'read:crm.Case',
1003
- 'read:activity.Activity',
1004
- 'read:crm.GamificationProfile',
1005
- 'write:crm.GamificationProfile',
1006
- ],
1007
- idempotent: true,
1008
- agent: true,
1009
- handler: async (ctx, args) => {
1010
- const error = command(ctx, args.idempotencyKey);
1011
- if (error)
1012
- return error;
1013
- const users = args.userId
1014
- ? await ctx.db.select('user.User', { id: args.userId, active: true })
1015
- : (await ctx.db.select('user.User', { active: true })).slice(0, Math.max(1, Math.min(500, n(args.limit ?? 200))));
1016
- /**
1017
- * Counted per user rather than by loading the pipeline into memory.
1018
- *
1019
- * The previous shape read every case, every activity and every link on
1020
- * every refresh, so the leaderboard grew a full-table scan per company as
1021
- * the pipeline grew. Each figure is now a counting query the
1022
- * `(companyId, assigneeUserId, …)` indexes already serve.
1023
- */
1024
- const profiles = [];
1025
- for (const user of users)
1026
- profiles.push(await gamificationProfile(ctx, user));
1027
- return {
1028
- ok: true,
1029
- profiles: profiles.sort((a, b) => n(b.points) - n(a.points) || String(a.id).localeCompare(String(b.id))),
1030
- };
1031
- },
1032
- }),
1033
- 'gamification.list': defineFn({
1034
- input: { limit: 'int?' },
1035
- output: { profiles: 'json' },
1036
- effects: ['read:crm.GamificationProfile', 'read:user.User'],
1037
- handler: async (ctx, args) => {
1038
- const users = new Map((await ctx.db.select('user.User')).map((user) => [String(user.id), user]));
1039
- const profiles = (await ctx.db.select('crm.GamificationProfile'))
1040
- .sort((a, b) => n(b.points) - n(a.points) || String(a.id).localeCompare(String(b.id)))
1041
- .slice(0, Math.max(1, Math.min(200, n(args.limit ?? 50))))
1042
- .map((profile) => ({
1043
- ...profile,
1044
- userName: users.get(String(profile.userId))?.name ?? profile.userId,
1045
- }));
1046
- return { profiles };
1047
- },
1048
- }),
1049
- /**
1050
- * The lists the relational pickers page through.
1051
- *
1052
- * Each one takes the `search` and `limit` the picker sends on every keystroke
1053
- * and returns a plain array, which is the shape `backend:relation.select`
1054
- * reads. They exist so a user filling in a case never has to leave the form to
1055
- * find a team, a stage or a tag.
1056
- */
1057
- 'team.list': defineFn({
1058
- input: { search: 'text?', limit: 'int?', includeArchived: 'bool?' },
1059
- output: { id: 'id', code: 'text', name: 'text', assignmentMode: 'text', active: 'bool' },
1060
- effects: ['read:crm.Team'],
1061
- agent: true,
1062
- handler: (ctx, args) => optionRows(ctx, 'crm.Team', args),
1063
- }),
1064
- 'stage.list': defineFn({
1065
- input: { search: 'text?', limit: 'int?', kind: 'text?', includeArchived: 'bool?' },
1066
- output: {
1067
- id: 'id',
1068
- code: 'text',
1069
- name: 'text',
1070
- sequence: 'int',
1071
- terminalState: 'text',
1072
- allowedKinds: 'json',
1073
- active: 'bool',
1074
- },
1075
- effects: ['read:crm.Stage'],
1076
- agent: true,
1077
- handler: async (ctx, args) => {
1078
- const rows = await optionRows(ctx, 'crm.Stage', args, (a, b) => n(a.sequence) - n(b.sequence));
1079
- return args.kind ? rows.filter((row) => stageKinds(row).includes(String(args.kind))) : rows;
1080
- },
1081
- }),
1082
- 'tag.list': defineFn({
1083
- input: { search: 'text?', limit: 'int?', includeArchived: 'bool?' },
1084
- output: { id: 'id', name: 'text', color: 'text?', active: 'bool' },
1085
- effects: ['read:crm.Tag'],
1086
- agent: true,
1087
- handler: (ctx, args) => optionRows(ctx, 'crm.Tag', args),
1088
- }),
1089
- /**
1090
- * Tags were reachable from the data model and from `case.save`, but nothing
1091
- * could create one — so the field could never hold a value. This is the
1092
- * missing half, shaped for the picker's inline editor: an id and a name, no
1093
- * idempotency key, because the picker mints the id itself.
1094
- */
1095
- 'tag.save': defineFn({
1096
- input: { id: 'id', name: 'text', color: 'text?', active: 'bool?' },
1097
- output: { ok: 'bool', id: 'id?', errors: 'json?' },
1098
- effects: ['read:crm.Tag', 'write:crm.Tag'],
1099
- idempotent: true,
1100
- agent: true,
1101
- handler: async (ctx, args) => {
1102
- const name = String(args.name ?? '').trim();
1103
- if (!name)
1104
- return invalid(issue('name', 'crm.error.required'));
1105
- const existing = (await ctx.db.select('crm.Tag', { id: args.id }))[0];
1106
- const clash = (await ctx.db.select('crm.Tag', { name })).find((row) => row.id !== args.id);
1107
- if (clash)
1108
- return invalid(issue('name', 'crm.error.duplicateName'));
1109
- const values = {
1110
- name,
1111
- color: args.color ? String(args.color) : (existing?.color ?? null),
1112
- active: args.active ?? existing?.active ?? true,
1113
- };
1114
- if (existing)
1115
- await ctx.db.update('crm.Tag', { id: args.id }, values);
1116
- else
1117
- await ctx.db.insert('crm.Tag', { id: args.id, ...values });
1118
- return { ok: true, id: args.id };
1119
- },
1120
- }),
1121
- 'tag.archive': defineFn({
1122
- input: { id: 'id' },
1123
- output: { ok: 'bool', id: 'id?', errors: 'json?' },
1124
- effects: ['read:crm.Tag', 'write:crm.Tag', 'read:crm.CaseTag', 'write:crm.CaseTag'],
1125
- idempotent: true,
1126
- agent: true,
1127
- handler: async (ctx, args) => {
1128
- const existing = (await ctx.db.select('crm.Tag', { id: args.id }))[0];
1129
- if (!existing)
1130
- return invalid(issue('id', 'crm.error.notFound'));
1131
- await ctx.db.update('crm.Tag', { id: args.id }, { active: false });
1132
- const CT = ctx.table('crm.CaseTag');
1133
- await ctx.db.del(deleteFrom(CT).where(eq(CT.tagId, args.id)));
1134
- return { ok: true, id: args.id };
1135
- },
1136
- }),
1137
- /**
1138
- * Cases as picker rows, for the fields that point at another case — today the
1139
- * merge source. `case.list` answers a paged envelope, which the picker cannot
1140
- * read, so this returns the array it expects under the same audience filter.
1141
- */
1142
- 'case.options': defineFn({
1143
- input: { search: 'text?', limit: 'int?', kind: 'text?', excludeId: 'id?' },
1144
- output: { id: 'id', name: 'text', ref: 'text?', kind: 'text' },
1145
- effects: [...caseReadEffects],
1146
- agent: true,
1147
- handler: async (ctx, args) => {
1148
- const found = await listCases(ctx, {
1149
- ...(args.search ? { search: args.search } : {}),
1150
- ...(args.kind ? { kind: args.kind } : {}),
1151
- limit: Math.max(1, Math.min(100, n(args.limit ?? 40))),
1152
- });
1153
- return found.rows
1154
- .filter((row) => !args.excludeId || row.id !== args.excludeId)
1155
- .map((row) => ({
1156
- id: row.id,
1157
- name: row.name,
1158
- kind: row.kind,
1159
- ref: [row.stageName, row.partnerName ?? row.email ?? row.phone].filter(Boolean).join(' · '),
1160
- }));
1161
- },
1162
- }),
1163
- 'team.member.list': defineFn({
1164
- input: { teamId: 'id?', search: 'text?', limit: 'int?' },
1165
- output: {
1166
- id: 'id',
1167
- teamId: 'id',
1168
- userId: 'id',
1169
- userName: 'text?',
1170
- capacity: 'int',
1171
- sequence: 'int',
1172
- assignedCount: 'int',
1173
- active: 'bool',
1174
- },
1175
- effects: ['read:crm.TeamMember', 'read:user.User'],
1176
- agent: true,
1177
- handler: async (ctx, args) => {
1178
- const rows = args.teamId
1179
- ? await ctx.db.select('crm.TeamMember', { teamId: args.teamId })
1180
- : await ctx.db.select('crm.TeamMember');
1181
- const users = new Map((await ctx.db.select('user.User')).map((user) => [String(user.id), user]));
1182
- const needle = normalized(args.search);
1183
- const named = rows.map((row) => ({
1184
- ...row,
1185
- userName: users.get(String(row.userId))?.name ?? String(row.userId),
1186
- }));
1187
- return named
1188
- .filter((row) => !needle || normalized(row.userName).includes(needle))
1189
- .sort((a, b) => n(a.sequence) - n(b.sequence) || String(a.id).localeCompare(String(b.id)))
1190
- .slice(0, Math.max(1, Math.min(200, n(args.limit ?? 100))));
1191
- },
1192
- }),
1193
- 'team.member.remove': defineFn({
1194
- input: { id: 'id' },
1195
- output: { ok: 'bool', id: 'id?', errors: 'json?' },
1196
- effects: ['read:crm.TeamMember', 'write:crm.TeamMember'],
1197
- idempotent: true,
1198
- agent: true,
1199
- handler: async (ctx, args) => {
1200
- const existing = (await ctx.db.select('crm.TeamMember', { id: args.id }))[0];
1201
- if (!existing)
1202
- return invalid(issue('id', 'crm.error.notFound'));
1203
- await ctx.db.update('crm.TeamMember', { id: args.id }, { active: false });
1204
- return { ok: true, id: args.id };
1205
- },
1206
- }),
1207
- 'configuration.get': defineFn({
1208
- input: {},
1209
- output: {
1210
- teams: 'json',
1211
- stages: 'json',
1212
- assignmentRules: 'json',
1213
- scoreRules: 'json',
1214
- },
1215
- effects: [
1216
- 'read:crm.Team',
1217
- 'read:crm.TeamMember',
1218
- 'read:crm.Stage',
1219
- 'read:crm.AssignmentRule',
1220
- 'read:crm.ScoreRule',
1221
- ],
1222
- handler: async (ctx) => {
1223
- const [teams, members, stages, assignmentRules, scoreRules] = await Promise.all([
1224
- ctx.db.select('crm.Team'),
1225
- ctx.db.select('crm.TeamMember'),
1226
- ctx.db.select('crm.Stage'),
1227
- ctx.db.select('crm.AssignmentRule'),
1228
- ctx.db.select('crm.ScoreRule'),
1229
- ]);
1230
- const membersByTeam = new Map();
1231
- for (const member of members) {
1232
- const key = String(member.teamId);
1233
- const rows = membersByTeam.get(key) ?? [];
1234
- rows.push(member);
1235
- membersByTeam.set(key, rows);
1236
- }
1237
- return {
1238
- teams: teams.map((team) => ({ ...team, members: membersByTeam.get(String(team.id)) ?? [] })),
1239
- stages,
1240
- assignmentRules,
1241
- scoreRules,
1242
- };
1243
- },
1244
- }),
1245
- 'team.save': saveConfiguration('crm.Team', (args, existing) => ({
1246
- code: String(args.code ?? existing?.code ?? args.id).trim(),
1247
- name: String(args.name ?? '').trim(),
1248
- active: args.active ?? existing?.active ?? true,
1249
- leaderUserId: args.leaderUserId ?? existing?.leaderUserId ?? null,
1250
- assignmentMode: ASSIGNMENT_MODES.includes(args.assignmentMode)
1251
- ? args.assignmentMode
1252
- : (existing?.assignmentMode ?? 'manual'),
1253
- assignmentCursor: existing?.assignmentCursor ?? 0,
1254
- version: n(existing?.version) + 1,
1255
- })),
1256
- 'team.member.save': defineFn({
1257
- input: {
1258
- id: 'id',
1259
- teamId: 'id',
1260
- userId: 'id',
1261
- capacity: 'int?',
1262
- sequence: 'int?',
1263
- active: 'bool?',
1264
- idempotencyKey: 'text',
1265
- },
1266
- output: { ok: 'bool', id: 'id?', errors: 'json?' },
1267
- effects: ['read:crm.Team', 'read:crm.TeamMember', 'write:crm.TeamMember', 'read:user.User'],
1268
- idempotent: true,
1269
- agent: true,
1270
- handler: async (ctx, args) => {
1271
- const error = command(ctx, args.idempotencyKey);
1272
- if (error)
1273
- return error;
1274
- const [team, user] = await Promise.all([
1275
- ctx.db.select('crm.Team', { id: args.teamId, active: true }),
1276
- ctx.db.select('user.User', { id: args.userId, active: true }),
1277
- ]);
1278
- if (!team[0])
1279
- return invalid(issue('teamId', 'crm.error.notFound'));
1280
- if (!user[0])
1281
- return invalid(issue('userId', 'crm.error.notFound'));
1282
- const existing = (await ctx.db.select('crm.TeamMember', { id: args.id }))[0];
1283
- const values = {
1284
- teamId: args.teamId,
1285
- userId: args.userId,
1286
- capacity: Math.max(1, n(args.capacity ?? existing?.capacity ?? 1)),
1287
- sequence: n(args.sequence ?? existing?.sequence ?? 10),
1288
- active: args.active ?? existing?.active ?? true,
1289
- assignedCount: n(existing?.assignedCount),
1290
- lastAssignedAt: existing?.lastAssignedAt ?? null,
1291
- };
1292
- if (existing)
1293
- await ctx.db.update('crm.TeamMember', { id: args.id }, values);
1294
- else
1295
- await ctx.db.insert('crm.TeamMember', { id: args.id, ...values });
1296
- return { ok: true, id: args.id };
1297
- },
1298
- }),
1299
- 'stage.save': saveConfiguration('crm.Stage', (args, existing) => ({
1300
- code: String(args.code ?? existing?.code ?? args.id).trim(),
1301
- name: String(args.name ?? '').trim(),
1302
- sequence: n(args.sequence ?? existing?.sequence ?? 10),
1303
- allowedKinds: Array.isArray(args.allowedKinds)
1304
- ? args.allowedKinds.map(String).filter((kind) => CASE_KINDS.includes(kind))
1305
- : (existing?.allowedKinds ?? ['lead', 'opportunity']),
1306
- terminalState: TERMINAL_STATES.includes(args.terminalState)
1307
- ? args.terminalState
1308
- : (existing?.terminalState ?? 'open'),
1309
- teamId: args.teamId ?? existing?.teamId ?? null,
1310
- fold: args.fold ?? existing?.fold ?? false,
1311
- active: args.active ?? existing?.active ?? true,
1312
- }), async (ctx, args) => {
1313
- if (!String(args.name ?? '').trim() || !String(args.code ?? '').trim())
1314
- return invalid(issue('name', 'crm.error.required'));
1315
- if (!Array.isArray(args.allowedKinds) || args.allowedKinds.length === 0)
1316
- return invalid(issue('allowedKinds', 'crm.error.required'));
1317
- if (args.teamId && !(await ctx.db.select('crm.Team', { id: args.teamId, active: true }))[0])
1318
- return invalid(issue('teamId', 'crm.error.notFound'));
1319
- return null;
1320
- }, ['read:crm.Team']),
1321
- 'assignmentRule.save': saveConfiguration('crm.AssignmentRule', (args, existing) => ({
1322
- name: String(args.name ?? '').trim(),
1323
- priority: n(args.priority ?? existing?.priority ?? 10),
1324
- allowedKinds: Array.isArray(args.allowedKinds)
1325
- ? args.allowedKinds.map(String)
1326
- : (existing?.allowedKinds ?? []),
1327
- teamId: args.teamId ?? existing?.teamId,
1328
- assigneeUserId: args.assigneeUserId ?? existing?.assigneeUserId ?? null,
1329
- utmSource: args.utmSource ?? existing?.utmSource ?? null,
1330
- minimumScore: args.minimumScore ?? existing?.minimumScore ?? null,
1331
- active: args.active ?? existing?.active ?? true,
1332
- }), async (ctx, args) => {
1333
- if (!String(args.name ?? '').trim() || !Array.isArray(args.allowedKinds) || !args.allowedKinds.length)
1334
- return invalid(issue('name', 'crm.error.required'));
1335
- const team = args.teamId
1336
- ? (await ctx.db.select('crm.Team', { id: args.teamId, active: true }))[0]
1337
- : null;
1338
- if (!team)
1339
- return invalid(issue('teamId', 'crm.error.notFound'));
1340
- if (args.assigneeUserId) {
1341
- const member = (await ctx.db.select('crm.TeamMember', {
1342
- teamId: args.teamId,
1343
- userId: args.assigneeUserId,
1344
- active: true,
1345
- }))[0];
1346
- if (!member && team.leaderUserId !== args.assigneeUserId)
1347
- return invalid(issue('assigneeUserId', 'crm.error.notTeamMember'));
1348
- }
1349
- return null;
1350
- }, ['read:crm.Team', 'read:crm.TeamMember']),
1351
- 'scoreRule.save': saveConfiguration('crm.ScoreRule', (args, existing) => ({
1352
- name: String(args.name ?? '').trim(),
1353
- field: String(args.field ?? '').trim(),
1354
- operator: String(args.operator ?? 'eq'),
1355
- value: String(args.value ?? ''),
1356
- points: String(args.points ?? '0'),
1357
- active: args.active ?? existing?.active ?? true,
1358
- sequence: n(args.sequence ?? existing?.sequence ?? 10),
1359
- }), (_ctx, args) => {
1360
- const field = String(args.field ?? '');
1361
- const operator = String(args.operator ?? '');
1362
- const allowed = field === 'expectedRevenue'
1363
- ? ['gte', 'eq']
1364
- : field === 'email' || field === 'utmSource'
1365
- ? ['eq', 'contains', 'present']
1366
- : [];
1367
- if (!String(args.name ?? '').trim())
1368
- return invalid(issue('name', 'crm.error.required'));
1369
- if (!allowed.includes(operator))
1370
- return invalid(issue('operator', 'crm.error.invalidKind'));
1371
- if (operator !== 'present' && !String(args.value ?? '').trim())
1372
- return invalid(issue('value', 'crm.error.required'));
1373
- if (field === 'expectedRevenue' && !Number.isFinite(Number(args.value)))
1374
- return invalid(issue('value', 'crm.error.invalidKind'));
1375
- return null;
1376
- }),
1377
- 'enrichment.preview': defineFn({
1378
- input: { caseId: 'id' },
1379
- output: { ok: 'bool', code: 'text', errors: 'json?' },
1380
- effects: ['read:crm.Case'],
1381
- handler: async (ctx, args) => ({
1382
- ok: false,
1383
- code: (await ensureCase(ctx, args.caseId)) ? 'provider_not_configured' : 'case_not_found',
1384
- }),
1385
- }),
1386
- 'mining.preview': defineFn({
1387
- input: { country: 'text?', industry: 'text?' },
1388
- output: { ok: 'bool', code: 'text' },
1389
- effects: [],
1390
- handler: () => ({ ok: false, code: 'provider_not_configured' }),
1391
- }),
1392
- };
1393
- export const createCaseId = () => randomUUID();
1394
- //# sourceMappingURL=functions.js.map