@elevasis/core 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1718 -19
- package/dist/index.js +369 -25
- package/dist/organization-model/index.d.ts +1718 -19
- package/dist/organization-model/index.js +369 -25
- package/dist/test-utils/index.d.ts +1108 -371
- package/dist/test-utils/index.js +357 -17
- package/package.json +5 -1
- package/src/__tests__/publish.test.ts +14 -13
- package/src/__tests__/template-core-compatibility.test.ts +4 -4
- package/src/_gen/__tests__/__snapshots__/contracts.md.snap +1109 -882
- package/src/auth/multi-tenancy/index.ts +3 -0
- package/src/auth/multi-tenancy/theme-presets.ts +45 -0
- package/src/auth/multi-tenancy/types.ts +57 -83
- package/src/auth/multi-tenancy/users/api-schemas.ts +165 -194
- package/src/business/acquisition/activity-events.ts +13 -4
- package/src/business/acquisition/api-schemas.test.ts +315 -4
- package/src/business/acquisition/api-schemas.ts +122 -8
- package/src/business/acquisition/build-templates.ts +44 -0
- package/src/business/acquisition/crm-next-action.test.ts +262 -0
- package/src/business/acquisition/crm-next-action.ts +220 -0
- package/src/business/acquisition/crm-priority.test.ts +216 -0
- package/src/business/acquisition/crm-priority.ts +349 -0
- package/src/business/acquisition/crm-state-actions.test.ts +151 -160
- package/src/business/acquisition/deal-ownership.test.ts +351 -0
- package/src/business/acquisition/deal-ownership.ts +120 -0
- package/src/business/acquisition/derive-actions.test.ts +101 -37
- package/src/business/acquisition/derive-actions.ts +102 -87
- package/src/business/acquisition/index.ts +10 -0
- package/src/business/acquisition/types.ts +400 -366
- package/src/business/crm/api-schemas.ts +40 -0
- package/src/business/crm/index.ts +1 -0
- package/src/business/deals/api-schemas.ts +79 -0
- package/src/business/deals/index.ts +1 -0
- package/src/business/projects/types.ts +124 -88
- package/src/execution/core/runner-types.ts +61 -80
- package/src/execution/engine/index.ts +4 -3
- package/src/execution/engine/tools/integration/server/adapters/gmail/gmail-tools.ts +105 -104
- package/src/execution/engine/tools/integration/server/adapters/instantly/instantly-tools.ts +1474 -1473
- package/src/execution/engine/tools/integration/server/adapters/millionverifier/millionverifier-tools.ts +103 -102
- package/src/execution/engine/tools/integration/server/adapters/signature-api/signature-api-tools.ts +182 -179
- package/src/execution/engine/tools/integration/server/adapters/stripe/stripe-tools.ts +310 -309
- package/src/execution/engine/tools/integration/tool.ts +255 -253
- package/src/execution/engine/tools/lead-service-types.ts +939 -924
- package/src/execution/engine/tools/messages.ts +43 -0
- package/src/execution/engine/tools/platform/acquisition/list-tools.ts +6 -5
- package/src/execution/engine/tools/platform/acquisition/types.ts +5 -2
- package/src/execution/engine/tools/platform/email/types.ts +97 -96
- package/src/execution/engine/tools/registry.ts +4 -3
- package/src/execution/engine/tools/tool-maps.ts +3 -1
- package/src/execution/engine/tools/types.ts +234 -233
- package/src/execution/engine/workflow/types.ts +195 -193
- package/src/execution/external/api-schemas.ts +40 -0
- package/src/execution/external/index.ts +1 -0
- package/src/knowledge/README.md +32 -0
- package/src/knowledge/__tests__/queries.test.ts +504 -0
- package/src/knowledge/format.ts +99 -0
- package/src/knowledge/index.ts +5 -0
- package/src/knowledge/queries.ts +256 -0
- package/src/organization-model/__tests__/defaults.test.ts +172 -172
- package/src/organization-model/__tests__/foundation.test.ts +7 -7
- package/src/organization-model/__tests__/icons.test.ts +27 -0
- package/src/organization-model/__tests__/knowledge.test.ts +214 -0
- package/src/organization-model/contracts.ts +17 -15
- package/src/organization-model/defaults.ts +74 -19
- package/src/organization-model/domains/knowledge.ts +53 -0
- package/src/organization-model/domains/navigation.ts +416 -399
- package/src/organization-model/domains/prospecting.ts +204 -1
- package/src/organization-model/domains/sales.test.ts +29 -0
- package/src/organization-model/domains/sales.ts +102 -0
- package/src/organization-model/domains/shared.ts +6 -5
- package/src/organization-model/foundation.ts +10 -6
- package/src/organization-model/graph/build.ts +209 -182
- package/src/organization-model/graph/schema.ts +37 -34
- package/src/organization-model/graph/types.ts +47 -31
- package/src/organization-model/icons.ts +81 -0
- package/src/organization-model/index.ts +8 -3
- package/src/organization-model/organization-model.mdx +1 -1
- package/src/organization-model/published.ts +103 -86
- package/src/organization-model/schema.ts +90 -85
- package/src/organization-model/types.ts +42 -35
- package/src/platform/constants/versions.ts +1 -1
- package/src/platform/index.ts +23 -27
- package/src/platform/registry/index.ts +0 -4
- package/src/platform/registry/resource-registry.ts +0 -77
- package/src/platform/registry/serialized-types.ts +148 -219
- package/src/platform/registry/stats-types.ts +60 -60
- package/src/reference/_generated/contracts.md +829 -595
- package/src/supabase/database.types.ts +2978 -2958
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_CRM_PRIORITY_RULE_CONFIG,
|
|
4
|
+
type CrmPriorityBucketDefinition,
|
|
5
|
+
type CrmPriorityBucketKey,
|
|
6
|
+
type CrmPriorityRuleConfig
|
|
7
|
+
} from '../../organization-model/domains/sales'
|
|
8
|
+
import { getDealOwnership } from './deal-ownership'
|
|
9
|
+
import type { DealPriority } from './types'
|
|
10
|
+
|
|
11
|
+
export interface CrmPriorityInput {
|
|
12
|
+
stage_key: string | null
|
|
13
|
+
state_key: string | null
|
|
14
|
+
activity_log: unknown
|
|
15
|
+
updated_at: string
|
|
16
|
+
created_at: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EvaluateCrmDealPriorityOptions {
|
|
20
|
+
config?: CrmPriorityRuleConfig | ResolvedCrmPriorityRuleConfig
|
|
21
|
+
now?: Date | string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CrmPriorityBucketKeySchema = z.enum(['needs_response', 'follow_up_due', 'waiting', 'stale', 'closed_low'])
|
|
25
|
+
|
|
26
|
+
export const CrmPriorityBucketOverrideSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
label: z.string().trim().min(1).max(80).optional(),
|
|
29
|
+
color: z.string().trim().min(1).max(40).optional(),
|
|
30
|
+
rank: z.number().int().min(0).max(10000).optional()
|
|
31
|
+
})
|
|
32
|
+
.strict()
|
|
33
|
+
|
|
34
|
+
const CrmPriorityBucketDefinitionSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
bucketKey: CrmPriorityBucketKeySchema,
|
|
37
|
+
label: z.string(),
|
|
38
|
+
rank: z.number().int(),
|
|
39
|
+
color: z.string()
|
|
40
|
+
})
|
|
41
|
+
.strict()
|
|
42
|
+
|
|
43
|
+
const CrmPriorityRuleConfigSchema = z
|
|
44
|
+
.object({
|
|
45
|
+
enabled: z.boolean().optional(),
|
|
46
|
+
buckets: z.array(CrmPriorityBucketDefinitionSchema),
|
|
47
|
+
closedStageKeys: z.array(z.string()),
|
|
48
|
+
followUpAfterDaysByStateKey: z.record(z.string(), z.number().int()),
|
|
49
|
+
staleAfterDays: z.number().int()
|
|
50
|
+
})
|
|
51
|
+
.strict()
|
|
52
|
+
|
|
53
|
+
export const CrmPriorityOverrideSchema = z
|
|
54
|
+
.object({
|
|
55
|
+
enabled: z.boolean().optional(),
|
|
56
|
+
staleAfterDays: z.number().int().min(1).max(365).optional(),
|
|
57
|
+
bucketOrder: z.array(CrmPriorityBucketKeySchema).optional(),
|
|
58
|
+
buckets: z.partialRecord(CrmPriorityBucketKeySchema, CrmPriorityBucketOverrideSchema).optional(),
|
|
59
|
+
followUpAfterDaysByStateKey: z
|
|
60
|
+
.record(z.string().trim().min(1).max(120), z.number().int().min(0).max(365))
|
|
61
|
+
.optional(),
|
|
62
|
+
closedStageKeys: z.array(z.string().trim().min(1).max(120)).optional()
|
|
63
|
+
})
|
|
64
|
+
.strict()
|
|
65
|
+
|
|
66
|
+
export type CrmPriorityBucketOverride = z.infer<typeof CrmPriorityBucketOverrideSchema>
|
|
67
|
+
export type CrmPriorityOverride = z.infer<typeof CrmPriorityOverrideSchema>
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolved CRM priority settings used by evaluators.
|
|
71
|
+
*
|
|
72
|
+
* When enabled is false, priority evaluation is intentionally neutral: all deals
|
|
73
|
+
* receive the configured waiting bucket and no follow-up/stale urgency is computed.
|
|
74
|
+
*/
|
|
75
|
+
export type ResolvedCrmPriorityRuleConfig = CrmPriorityRuleConfig & {
|
|
76
|
+
enabled: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface ParsedActivity {
|
|
80
|
+
type: string | null
|
|
81
|
+
occurredAt: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000
|
|
85
|
+
|
|
86
|
+
export function evaluateCrmDealPriority(
|
|
87
|
+
input: CrmPriorityInput,
|
|
88
|
+
options: EvaluateCrmDealPriorityOptions = {}
|
|
89
|
+
): DealPriority {
|
|
90
|
+
const config = resolveCrmPriorityRuleConfig(options.config)
|
|
91
|
+
const now = parseDate(options.now ?? new Date()) ?? new Date()
|
|
92
|
+
const latestActivityAt = getLatestActivityAt(input)
|
|
93
|
+
const nextActionAt = getNextActionAt(input.state_key, latestActivityAt, config)
|
|
94
|
+
|
|
95
|
+
if (!config.enabled) {
|
|
96
|
+
return buildPriority('waiting', config, {
|
|
97
|
+
reason: 'CRM priority evaluation is disabled.',
|
|
98
|
+
latestActivityAt,
|
|
99
|
+
nextActionAt: null
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (input.stage_key && config.closedStageKeys.includes(input.stage_key)) {
|
|
104
|
+
return buildPriority('closed_low', config, {
|
|
105
|
+
reason: `Deal is in ${input.stage_key}.`,
|
|
106
|
+
latestActivityAt,
|
|
107
|
+
nextActionAt: null
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ownership = getDealOwnership(input)
|
|
112
|
+
|
|
113
|
+
if (ownership === 'us') {
|
|
114
|
+
return buildPriority('needs_response', config, {
|
|
115
|
+
reason: 'Lead replied last — we owe the next move.',
|
|
116
|
+
latestActivityAt,
|
|
117
|
+
nextActionAt
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ownership === 'them' or null: check age against staleAfterDays first
|
|
122
|
+
if (latestActivityAt) {
|
|
123
|
+
const latestActivityDate = parseDate(latestActivityAt)
|
|
124
|
+
if (latestActivityDate && now.getTime() - latestActivityDate.getTime() >= config.staleAfterDays * MS_PER_DAY) {
|
|
125
|
+
return buildPriority('stale', config, {
|
|
126
|
+
reason: `No meaningful activity in ${config.staleAfterDays} days.`,
|
|
127
|
+
latestActivityAt,
|
|
128
|
+
nextActionAt: null
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (ownership === 'them') {
|
|
134
|
+
if (nextActionAt) {
|
|
135
|
+
const nextActionDate = parseDate(nextActionAt)
|
|
136
|
+
if (nextActionDate && nextActionDate <= now) {
|
|
137
|
+
return buildPriority('follow_up_due', config, {
|
|
138
|
+
reason: 'Configured follow-up window has elapsed.',
|
|
139
|
+
latestActivityAt,
|
|
140
|
+
nextActionAt
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return buildPriority('waiting', config, {
|
|
146
|
+
reason: 'Waiting on their reply.',
|
|
147
|
+
latestActivityAt,
|
|
148
|
+
nextActionAt
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ownership === null (no inbound/outbound events): fall back to follow_up/waiting
|
|
153
|
+
if (nextActionAt) {
|
|
154
|
+
const nextActionDate = parseDate(nextActionAt)
|
|
155
|
+
if (nextActionDate && nextActionDate <= now) {
|
|
156
|
+
return buildPriority('follow_up_due', config, {
|
|
157
|
+
reason: 'Configured follow-up window has elapsed.',
|
|
158
|
+
latestActivityAt,
|
|
159
|
+
nextActionAt
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return buildPriority('waiting', config, {
|
|
164
|
+
reason: 'Waiting until the configured next action time.',
|
|
165
|
+
latestActivityAt,
|
|
166
|
+
nextActionAt
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return buildPriority('waiting', config, {
|
|
171
|
+
reason: 'No immediate response or follow-up is due.',
|
|
172
|
+
latestActivityAt,
|
|
173
|
+
nextActionAt: null
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function resolveCrmPriorityRuleConfig(input?: unknown): ResolvedCrmPriorityRuleConfig {
|
|
178
|
+
const candidate = extractPriorityOverride(input)
|
|
179
|
+
const fullConfigResult = CrmPriorityRuleConfigSchema.safeParse(candidate)
|
|
180
|
+
|
|
181
|
+
if (fullConfigResult.success) return buildResolvedConfig(fullConfigResult.data)
|
|
182
|
+
|
|
183
|
+
const result = CrmPriorityOverrideSchema.safeParse(candidate)
|
|
184
|
+
|
|
185
|
+
if (!result.success) return buildDefaultResolvedConfig()
|
|
186
|
+
|
|
187
|
+
return mergeCrmPriorityOverride(result.data)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function mergeCrmPriorityOverride(override: CrmPriorityOverride): ResolvedCrmPriorityRuleConfig {
|
|
191
|
+
const defaultConfig = buildDefaultResolvedConfig()
|
|
192
|
+
const bucketOverrides = override.buckets ?? {}
|
|
193
|
+
const orderedBucketKeys = override.bucketOrder ? [...new Set(override.bucketOrder)] : []
|
|
194
|
+
const orderRanks = new Map<CrmPriorityBucketKey, number>()
|
|
195
|
+
|
|
196
|
+
orderedBucketKeys.forEach((bucketKey, index) => {
|
|
197
|
+
orderRanks.set(bucketKey, (index + 1) * 10)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const buckets = defaultConfig.buckets
|
|
201
|
+
.map((bucket) => {
|
|
202
|
+
const bucketOverride = bucketOverrides[bucket.bucketKey]
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...bucket,
|
|
206
|
+
rank: bucketOverride?.rank ?? orderRanks.get(bucket.bucketKey) ?? bucket.rank,
|
|
207
|
+
label: bucketOverride?.label ?? bucket.label,
|
|
208
|
+
color: bucketOverride?.color ?? bucket.color
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
.sort((a, b) => a.rank - b.rank)
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
enabled: override.enabled ?? defaultConfig.enabled,
|
|
215
|
+
buckets,
|
|
216
|
+
closedStageKeys: override.closedStageKeys ?? defaultConfig.closedStageKeys,
|
|
217
|
+
followUpAfterDaysByStateKey: {
|
|
218
|
+
...defaultConfig.followUpAfterDaysByStateKey,
|
|
219
|
+
...(override.followUpAfterDaysByStateKey ?? {})
|
|
220
|
+
},
|
|
221
|
+
staleAfterDays: override.staleAfterDays ?? defaultConfig.staleAfterDays
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function buildDefaultResolvedConfig(): ResolvedCrmPriorityRuleConfig {
|
|
226
|
+
return buildResolvedConfig(DEFAULT_CRM_PRIORITY_RULE_CONFIG)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function buildResolvedConfig(config: CrmPriorityRuleConfig & { enabled?: boolean }): ResolvedCrmPriorityRuleConfig {
|
|
230
|
+
return {
|
|
231
|
+
enabled: config.enabled ?? true,
|
|
232
|
+
buckets: config.buckets.map((bucket) => ({ ...bucket })),
|
|
233
|
+
closedStageKeys: [...config.closedStageKeys],
|
|
234
|
+
followUpAfterDaysByStateKey: { ...config.followUpAfterDaysByStateKey },
|
|
235
|
+
staleAfterDays: config.staleAfterDays
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function extractPriorityOverride(input: unknown): unknown {
|
|
240
|
+
if (!isPlainRecord(input)) return input
|
|
241
|
+
|
|
242
|
+
const crm = input.crm
|
|
243
|
+
if (isPlainRecord(crm) && Object.prototype.hasOwnProperty.call(crm, 'priority')) {
|
|
244
|
+
return crm.priority
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return input
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
251
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildPriority(
|
|
255
|
+
bucketKey: CrmPriorityBucketKey,
|
|
256
|
+
config: CrmPriorityRuleConfig,
|
|
257
|
+
values: Pick<DealPriority, 'reason' | 'latestActivityAt' | 'nextActionAt'>
|
|
258
|
+
): DealPriority {
|
|
259
|
+
const bucket = findBucket(config.buckets, bucketKey)
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
bucketKey: bucket.bucketKey,
|
|
263
|
+
rank: bucket.rank,
|
|
264
|
+
label: bucket.label,
|
|
265
|
+
color: bucket.color,
|
|
266
|
+
reason: values.reason,
|
|
267
|
+
latestActivityAt: values.latestActivityAt,
|
|
268
|
+
nextActionAt: values.nextActionAt
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function findBucket(
|
|
273
|
+
buckets: CrmPriorityBucketDefinition[],
|
|
274
|
+
bucketKey: CrmPriorityBucketKey
|
|
275
|
+
): CrmPriorityBucketDefinition {
|
|
276
|
+
const bucket = buckets.find((candidate) => candidate.bucketKey === bucketKey)
|
|
277
|
+
if (bucket) return bucket
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
bucketKey,
|
|
281
|
+
label: bucketKey,
|
|
282
|
+
rank: Number.MAX_SAFE_INTEGER,
|
|
283
|
+
color: 'gray'
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getLatestActivityAt(input: CrmPriorityInput): string | null {
|
|
288
|
+
const timestamps = getParsedActivities(input.activity_log).map((activity) => activity.occurredAt)
|
|
289
|
+
const fallback = normalizeTimestamp(input.updated_at) ?? normalizeTimestamp(input.created_at)
|
|
290
|
+
if (fallback) timestamps.push(fallback)
|
|
291
|
+
|
|
292
|
+
return timestamps.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function getParsedActivities(activityLog: unknown): ParsedActivity[] {
|
|
296
|
+
if (!Array.isArray(activityLog)) return []
|
|
297
|
+
|
|
298
|
+
const parsed: ParsedActivity[] = []
|
|
299
|
+
|
|
300
|
+
for (const entry of activityLog) {
|
|
301
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
|
|
302
|
+
|
|
303
|
+
const record = entry as Record<string, unknown>
|
|
304
|
+
const occurredAt =
|
|
305
|
+
normalizeTimestamp(record.timestamp) ??
|
|
306
|
+
normalizeTimestamp(record.occurredAt) ??
|
|
307
|
+
normalizeTimestamp(record.createdAt) ??
|
|
308
|
+
normalizeTimestamp(record.updatedAt) ??
|
|
309
|
+
normalizeTimestamp(record.sentAt)
|
|
310
|
+
|
|
311
|
+
if (!occurredAt) continue
|
|
312
|
+
|
|
313
|
+
parsed.push({
|
|
314
|
+
type: typeof record.type === 'string' ? record.type : null,
|
|
315
|
+
occurredAt
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return parsed
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getNextActionAt(
|
|
323
|
+
stateKey: string | null,
|
|
324
|
+
latestActivityAt: string | null,
|
|
325
|
+
config: CrmPriorityRuleConfig
|
|
326
|
+
): string | null {
|
|
327
|
+
if (!stateKey || !latestActivityAt) return null
|
|
328
|
+
|
|
329
|
+
const days = config.followUpAfterDaysByStateKey[stateKey]
|
|
330
|
+
if (days === undefined) return null
|
|
331
|
+
|
|
332
|
+
const latestActivityDate = parseDate(latestActivityAt)
|
|
333
|
+
if (!latestActivityDate) return null
|
|
334
|
+
|
|
335
|
+
return new Date(latestActivityDate.getTime() + days * MS_PER_DAY).toISOString()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeTimestamp(value: unknown): string | null {
|
|
339
|
+
const date = parseDate(value)
|
|
340
|
+
return date ? date.toISOString() : null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseDate(value: unknown): Date | null {
|
|
344
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
|
|
345
|
+
if (typeof value !== 'string') return null
|
|
346
|
+
|
|
347
|
+
const date = new Date(value)
|
|
348
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
349
|
+
}
|
|
@@ -1,160 +1,151 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { DEFAULT_ORGANIZATION_MODEL_SALES, LEAD_GEN_PIPELINE_DEFINITIONS } from '../../organization-model/domains/sales'
|
|
3
|
-
import { ActivityEventSchema } from './activity-events'
|
|
4
|
-
import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
|
|
5
|
-
import { deriveActions } from './derive-actions'
|
|
6
|
-
import type { DealStage } from './types'
|
|
7
|
-
|
|
8
|
-
const DEAL_STAGES = [
|
|
9
|
-
'interested',
|
|
10
|
-
'proposal',
|
|
11
|
-
'closing',
|
|
12
|
-
'closed_won',
|
|
13
|
-
'closed_lost',
|
|
14
|
-
'nurturing'
|
|
15
|
-
] as const satisfies readonly DealStage[]
|
|
16
|
-
|
|
17
|
-
function deal(stageKey: string | null, stateKey: string | null = null): Parameters<typeof deriveActions>[0] {
|
|
18
|
-
return { stage_key: stageKey, state_key: stateKey } as Parameters<typeof deriveActions>[0]
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
describe('CRM deal action derivation', () => {
|
|
22
|
-
it('derives the exact base actions for interested deals', () => {
|
|
23
|
-
expect(deriveActions(deal('interested'))).toEqual([
|
|
24
|
-
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
25
|
-
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
26
|
-
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
27
|
-
])
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it.each([
|
|
31
|
-
['discovery_replied', { key: 'send_link', label: 'Send Booking Link' }],
|
|
32
|
-
['discovery_link_sent', { key: 'send_nudge', label: 'Send Nudge' }],
|
|
33
|
-
['discovery_booking_cancelled', { key: 'rebook', label: 'Rebook' }]
|
|
34
|
-
] as const)('adds the expected workflow action for interested/%s', (stateKey, expectedAction) => {
|
|
35
|
-
const expected = [
|
|
36
|
-
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
37
|
-
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
38
|
-
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
39
|
-
expectedAction
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
{ key: '
|
|
58
|
-
{ key: '
|
|
59
|
-
{ key: '
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
[{ type: '
|
|
98
|
-
[{ type: '
|
|
99
|
-
[{ type: '
|
|
100
|
-
[{ type: '
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
stateKey: state.stateKey
|
|
153
|
-
}).success
|
|
154
|
-
).toBe(true)
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { DEFAULT_ORGANIZATION_MODEL_SALES, LEAD_GEN_PIPELINE_DEFINITIONS } from '../../organization-model/domains/sales'
|
|
3
|
+
import { ActivityEventSchema } from './activity-events'
|
|
4
|
+
import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
|
|
5
|
+
import { deriveActions } from './derive-actions'
|
|
6
|
+
import type { DealStage } from './types'
|
|
7
|
+
|
|
8
|
+
const DEAL_STAGES = [
|
|
9
|
+
'interested',
|
|
10
|
+
'proposal',
|
|
11
|
+
'closing',
|
|
12
|
+
'closed_won',
|
|
13
|
+
'closed_lost',
|
|
14
|
+
'nurturing'
|
|
15
|
+
] as const satisfies readonly DealStage[]
|
|
16
|
+
|
|
17
|
+
function deal(stageKey: string | null, stateKey: string | null = null): Parameters<typeof deriveActions>[0] {
|
|
18
|
+
return { stage_key: stageKey, state_key: stateKey } as Parameters<typeof deriveActions>[0]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('CRM deal action derivation', () => {
|
|
22
|
+
it('derives the exact base actions for interested deals', () => {
|
|
23
|
+
expect(deriveActions(deal('interested'))).toEqual([
|
|
24
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
25
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
26
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
27
|
+
])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['discovery_replied', { key: 'send_link', label: 'Send Booking Link' }],
|
|
32
|
+
['discovery_link_sent', { key: 'send_nudge', label: 'Send Nudge' }],
|
|
33
|
+
['discovery_booking_cancelled', { key: 'rebook', label: 'Rebook' }]
|
|
34
|
+
] as const)('adds the expected workflow action for interested/%s', (stateKey, expectedAction) => {
|
|
35
|
+
const expected = [
|
|
36
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
37
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
38
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
39
|
+
expectedAction
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
expect(deriveActions(deal('interested', stateKey))).toEqual(expected)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('adds nudge and no-show actions for interested/discovery_nudging', () => {
|
|
46
|
+
expect(deriveActions(deal('interested', 'discovery_nudging'))).toEqual([
|
|
47
|
+
{ key: 'move_to_proposal', label: 'Move to Proposal' },
|
|
48
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
49
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' },
|
|
50
|
+
{ key: 'send_nudge', label: 'Send Nudge' },
|
|
51
|
+
{ key: 'mark_no_show', label: 'Mark No-Show' }
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('derives exact proposal and closing transitions', () => {
|
|
56
|
+
expect(deriveActions(deal('proposal'))).toEqual([
|
|
57
|
+
{ key: 'move_to_closing', label: 'Move to Closing' },
|
|
58
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
59
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
expect(deriveActions(deal('closing'))).toEqual([
|
|
63
|
+
{ key: 'move_to_closed_won', label: 'Close Won' },
|
|
64
|
+
{ key: 'move_to_closed_lost', label: 'Close Lost' },
|
|
65
|
+
{ key: 'move_to_nurturing', label: 'Move to Nurturing' }
|
|
66
|
+
])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it.each(['closed_won', 'closed_lost', 'nurturing', 'legacy_stage', null] as const)(
|
|
70
|
+
'derives no actions for terminal or non-canonical stage %s',
|
|
71
|
+
(stageKey) => {
|
|
72
|
+
expect(deriveActions(deal(stageKey))).toEqual([])
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('ActivityEventSchema', () => {
|
|
78
|
+
const timestamp = '2026-04-27T12:34:56.000Z'
|
|
79
|
+
|
|
80
|
+
// Platform events only (8 members). Domain events (reply_received, booking_nudge_sent, etc.)
|
|
81
|
+
// live in @repo/elevasis-operations as CrmDomainActivityEventSchema.
|
|
82
|
+
// See Open Decision #5 in crm-action-system.mdx for rationale.
|
|
83
|
+
it.each([
|
|
84
|
+
[{ type: 'stage_change', timestamp, stageBefore: 'interested', stageAfter: 'proposal', reason: 'qualified' }],
|
|
85
|
+
[{ type: 'state_change', timestamp, stateBefore: null, stateAfter: 'discovery_replied' }],
|
|
86
|
+
[{ type: 'action_taken', timestamp, actionKey: 'send_link', payload: { channel: 'email' } }],
|
|
87
|
+
[{ type: 'approval_created', timestamp, commandId: 'cmd_123', dealStageBefore: 'proposal' }],
|
|
88
|
+
[{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'superseded' }],
|
|
89
|
+
[{ type: 'approval_stale', timestamp, commandId: 'cmd_123', dealStageAfter: 'closing' }],
|
|
90
|
+
[{ type: 'task_created', timestamp, taskId: 'task_123' }],
|
|
91
|
+
[{ type: 'deal_created', timestamp }]
|
|
92
|
+
])('accepts expected %s events', (event) => {
|
|
93
|
+
expect(ActivityEventSchema.safeParse(event).success).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
[{ type: 'deal_created', timestamp: 'not-a-date' }],
|
|
98
|
+
[{ type: 'unknown_event', timestamp }],
|
|
99
|
+
[{ type: 'stage_change', timestamp, stageBefore: 'interested' }],
|
|
100
|
+
[{ type: 'approval_resolved', timestamp, commandId: 'cmd_123', resolution: 'approved' }],
|
|
101
|
+
[{ type: 'booking_nudge_sent', timestamp, followupDay: '2' }],
|
|
102
|
+
[{ type: 'action_taken', timestamp }]
|
|
103
|
+
])('rejects malformed event payload %o', (event) => {
|
|
104
|
+
expect(ActivityEventSchema.safeParse(event).success).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('CRM stage and transition vocabulary contracts', () => {
|
|
109
|
+
it('keeps DealStage, DealStageSchema, and the default sales pipeline stages aligned', () => {
|
|
110
|
+
const defaultPipeline = DEFAULT_ORGANIZATION_MODEL_SALES.pipelines.find(
|
|
111
|
+
(pipeline) => pipeline.id === DEFAULT_ORGANIZATION_MODEL_SALES.defaultPipelineId
|
|
112
|
+
)
|
|
113
|
+
const defaultSalesStages = defaultPipeline?.stages
|
|
114
|
+
.toSorted((left, right) => left.order - right.order)
|
|
115
|
+
.map((stage) => stage.id)
|
|
116
|
+
|
|
117
|
+
expect(defaultSalesStages).toEqual([...DEAL_STAGES])
|
|
118
|
+
expect(DealStageSchema.options).toEqual([...DEAL_STAGES])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('accepts every canonical CRM deal stage in transition requests', () => {
|
|
122
|
+
for (const stageKey of DEAL_STAGES) {
|
|
123
|
+
expect(
|
|
124
|
+
TransitionItemRequestSchema.safeParse({
|
|
125
|
+
pipelineKey: DEFAULT_ORGANIZATION_MODEL_SALES.defaultPipelineId,
|
|
126
|
+
stageKey,
|
|
127
|
+
stateKey: null,
|
|
128
|
+
expectedUpdatedAt: '2026-04-27T12:34:56.000Z'
|
|
129
|
+
}).success
|
|
130
|
+
).toBe(true)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('accepts canonical lead-gen stage/state pairs in transition requests', () => {
|
|
135
|
+
for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
|
|
136
|
+
for (const pipeline of pipelineDefinitions) {
|
|
137
|
+
for (const stage of pipeline.stages) {
|
|
138
|
+
for (const state of stage.states) {
|
|
139
|
+
expect(
|
|
140
|
+
TransitionItemRequestSchema.safeParse({
|
|
141
|
+
pipelineKey: pipeline.pipelineKey,
|
|
142
|
+
stageKey: stage.stageKey,
|
|
143
|
+
stateKey: state.stateKey
|
|
144
|
+
}).success
|
|
145
|
+
).toBe(true)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|