@elevasis/core 0.29.0 → 0.31.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.
Files changed (42) hide show
  1. package/dist/auth/index.d.ts +5289 -0
  2. package/dist/auth/index.js +595 -0
  3. package/dist/index.d.ts +1 -2
  4. package/dist/index.js +4 -105
  5. package/dist/organization-model/index.d.ts +1 -2
  6. package/dist/organization-model/index.js +4 -105
  7. package/dist/test-utils/index.d.ts +20 -0
  8. package/dist/test-utils/index.js +3 -104
  9. package/package.json +5 -1
  10. package/src/__tests__/publish.test.ts +8 -7
  11. package/src/_gen/__tests__/__snapshots__/contracts.md.snap +376 -446
  12. package/src/auth/__tests__/access-key-coverage.test.ts +42 -0
  13. package/src/auth/__tests__/access-key-scan.ts +117 -0
  14. package/src/auth/__tests__/access-keys.test.ts +81 -0
  15. package/src/auth/__tests__/access-model.test.ts +257 -0
  16. package/src/auth/__tests__/access-test-fixtures.ts +50 -0
  17. package/src/auth/__tests__/key-catalog-drift.test.ts +33 -0
  18. package/src/auth/__tests__/platform-admin-bypass-parity.test.ts +67 -0
  19. package/src/auth/access-keys.ts +173 -0
  20. package/src/auth/access-model.ts +185 -0
  21. package/src/auth/index.ts +6 -2
  22. package/src/auth/multi-tenancy/memberships/membership.ts +2 -4
  23. package/src/auth/multi-tenancy/permissions.ts +1 -1
  24. package/src/auth/multi-tenancy/types.ts +3 -12
  25. package/src/business/acquisition/api-schemas.test.ts +69 -5
  26. package/src/business/acquisition/crm-state-actions.test.ts +24 -6
  27. package/src/business/pdf/sections/__tests__/proposal-document.test.ts +146 -0
  28. package/src/business/pdf/sections/acceptance.ts +114 -112
  29. package/src/business/pdf/sections/proposal-document.ts +206 -200
  30. package/src/execution/engine/index.ts +440 -439
  31. package/src/execution/engine/tools/integration/types/clickup.ts +57 -0
  32. package/src/execution/engine/tools/integration/types/index.ts +20 -19
  33. package/src/execution/engine/tools/tool-maps.ts +16 -0
  34. package/src/organization-model/__tests__/domains/entities.test.ts +35 -56
  35. package/src/organization-model/domains/entities.ts +0 -103
  36. package/src/organization-model/domains/sales.test.ts +35 -28
  37. package/src/organization-model/domains/sales.ts +0 -85
  38. package/src/organization-model/published.ts +0 -1
  39. package/src/organization-model/schema.ts +2 -2
  40. package/src/reference/_generated/contracts.md +0 -94
  41. package/src/reference/glossary.md +8 -6
  42. package/src/supabase/database.types.ts +10 -0
@@ -0,0 +1,173 @@
1
+ import { z } from 'zod'
2
+ import { listAllSystems } from '../organization-model/helpers'
3
+ import type { OrganizationModel } from '../organization-model/types'
4
+ import { PERMISSIONS, type PermissionKey } from './multi-tenancy/permissions'
5
+
6
+ export const DEFAULT_ACCESS_ACTION = 'view' as const
7
+ export const PLATFORM_ADMIN_ACCESS_KEY = 'platform.admin' as const
8
+ export const PLATFORM_ADMIN_ACCESS_KEY_SHORTHAND = 'platformAdmin' as const
9
+
10
+ export const AccessActionSchema = z.enum(['view', 'manage'])
11
+
12
+ export const AccessKeyObjectSchema = z
13
+ .object({
14
+ systemPath: z.string().trim().min(1),
15
+ action: AccessActionSchema.default(DEFAULT_ACCESS_ACTION)
16
+ })
17
+ .strict()
18
+
19
+ export const AccessKeyInputSchema = z.union([z.string().trim().min(1), AccessKeyObjectSchema])
20
+ export const NormalizedAccessKeySchema = AccessKeyObjectSchema
21
+ export const AccessKeySchema = AccessKeyInputSchema
22
+
23
+ export type AccessAction = z.infer<typeof AccessActionSchema>
24
+ export type AccessKeyObject = z.infer<typeof AccessKeyObjectSchema>
25
+ export type AccessKeyInput = z.input<typeof AccessKeyInputSchema>
26
+ export type NormalizedAccessKey = z.infer<typeof NormalizedAccessKeySchema>
27
+ export type AccessKey = NormalizedAccessKey
28
+
29
+ export type AccessCatalogEntrySource = 'om-system' | 'diagnostic' | 'platform' | 'permission'
30
+
31
+ export interface AccessCatalogEntry {
32
+ key: NormalizedAccessKey
33
+ source: AccessCatalogEntrySource
34
+ rolePermission?: string
35
+ }
36
+
37
+ export interface AccessKeyCatalog {
38
+ bySystemPath: ReadonlyMap<string, readonly AccessCatalogEntry[]>
39
+ entries: readonly AccessCatalogEntry[]
40
+ }
41
+
42
+ export const DIAGNOSTIC_VIEW_ACCESS_KEYS = [
43
+ 'diagnostic.operations.overview',
44
+ 'diagnostic.operations.recent-executions',
45
+ 'diagnostic.monitoring.execution-logs',
46
+ 'diagnostic.monitoring.notifications'
47
+ ] as const
48
+
49
+ export const AccessKeys = {
50
+ platformAdmin: { systemPath: PLATFORM_ADMIN_ACCESS_KEY, action: DEFAULT_ACCESS_ACTION },
51
+ organizationManage: { systemPath: 'permission.org', action: 'manage' },
52
+ membersManage: { systemPath: 'permission.members', action: 'manage' },
53
+ rolesManage: { systemPath: 'permission.roles', action: 'manage' },
54
+ secretsManage: { systemPath: 'permission.secrets', action: 'manage' },
55
+ operationsRead: { systemPath: 'permission.operations', action: DEFAULT_ACCESS_ACTION },
56
+ operationsManage: { systemPath: 'permission.operations', action: 'manage' },
57
+ acquisitionManage: { systemPath: 'permission.acquisition', action: 'manage' },
58
+ projectsManage: { systemPath: 'permission.projects', action: 'manage' },
59
+ clientsManage: { systemPath: 'permission.clients', action: 'manage' },
60
+ operationsOverview: { systemPath: 'diagnostic.operations.overview', action: DEFAULT_ACCESS_ACTION },
61
+ operationsRecentExecutions: { systemPath: 'diagnostic.operations.recent-executions', action: DEFAULT_ACCESS_ACTION },
62
+ monitoringExecutionLogs: { systemPath: 'diagnostic.monitoring.execution-logs', action: DEFAULT_ACCESS_ACTION },
63
+ monitoringNotifications: { systemPath: 'diagnostic.monitoring.notifications', action: DEFAULT_ACCESS_ACTION }
64
+ } as const satisfies Record<string, NormalizedAccessKey>
65
+
66
+ const PERMISSION_ACCESS_KEY_DEFINITIONS: readonly {
67
+ key: NormalizedAccessKey
68
+ rolePermission: PermissionKey
69
+ }[] = [
70
+ { key: AccessKeys.organizationManage, rolePermission: PERMISSIONS.ORG_MANAGE },
71
+ { key: AccessKeys.membersManage, rolePermission: PERMISSIONS.MEMBERS_MANAGE },
72
+ { key: AccessKeys.rolesManage, rolePermission: PERMISSIONS.ROLES_MANAGE },
73
+ { key: AccessKeys.secretsManage, rolePermission: PERMISSIONS.SECRETS_MANAGE },
74
+ { key: AccessKeys.operationsRead, rolePermission: PERMISSIONS.OPERATIONS_READ },
75
+ { key: AccessKeys.operationsManage, rolePermission: PERMISSIONS.OPERATIONS_MANAGE },
76
+ { key: AccessKeys.acquisitionManage, rolePermission: PERMISSIONS.ACQUISITION_MANAGE },
77
+ { key: AccessKeys.projectsManage, rolePermission: PERMISSIONS.PROJECTS_MANAGE },
78
+ { key: AccessKeys.clientsManage, rolePermission: PERMISSIONS.CLIENTS_MANAGE }
79
+ ]
80
+
81
+ export function normalizeAccessKey(input: AccessKeyInput): NormalizedAccessKey {
82
+ const parsed = AccessKeyInputSchema.parse(input)
83
+ const normalized =
84
+ typeof parsed === 'string'
85
+ ? {
86
+ systemPath: parsed === PLATFORM_ADMIN_ACCESS_KEY_SHORTHAND ? PLATFORM_ADMIN_ACCESS_KEY : parsed,
87
+ action: DEFAULT_ACCESS_ACTION
88
+ }
89
+ : parsed
90
+
91
+ return NormalizedAccessKeySchema.parse(normalized)
92
+ }
93
+
94
+ export function accessKeyToString(input: AccessKeyInput): string {
95
+ const key = normalizeAccessKey(input)
96
+ return `${key.systemPath}:${key.action}`
97
+ }
98
+
99
+ export function rolePermissionForAccessKey(key: NormalizedAccessKey): string | undefined {
100
+ if (key.action === DEFAULT_ACCESS_ACTION) return undefined
101
+ return `${key.systemPath}.${key.action}`
102
+ }
103
+
104
+ export interface DeriveAccessKeyCatalogOptions {
105
+ diagnosticKeys?: readonly string[]
106
+ includeManageActions?: boolean
107
+ }
108
+
109
+ function groupCatalogEntries(entries: readonly AccessCatalogEntry[]): ReadonlyMap<string, readonly AccessCatalogEntry[]> {
110
+ const grouped = new Map<string, AccessCatalogEntry[]>()
111
+
112
+ for (const entry of entries) {
113
+ const existing = grouped.get(entry.key.systemPath) ?? []
114
+ existing.push(entry)
115
+ grouped.set(entry.key.systemPath, existing)
116
+ }
117
+
118
+ return grouped
119
+ }
120
+
121
+ function buildCatalogEntry(systemPath: string, action: AccessAction, source: AccessCatalogEntrySource): AccessCatalogEntry {
122
+ const key = normalizeAccessKey({ systemPath, action })
123
+ return {
124
+ key,
125
+ source,
126
+ rolePermission: rolePermissionForAccessKey(key)
127
+ }
128
+ }
129
+
130
+ export function deriveAccessKeyCatalog(
131
+ organizationModel: OrganizationModel,
132
+ options: DeriveAccessKeyCatalogOptions = {}
133
+ ): AccessKeyCatalog {
134
+ const { diagnosticKeys = DIAGNOSTIC_VIEW_ACCESS_KEYS, includeManageActions = true } = options
135
+ const entries: AccessCatalogEntry[] = [
136
+ buildCatalogEntry(PLATFORM_ADMIN_ACCESS_KEY, DEFAULT_ACCESS_ACTION, 'platform')
137
+ ]
138
+
139
+ for (const { path } of listAllSystems(organizationModel)) {
140
+ entries.push(buildCatalogEntry(path, DEFAULT_ACCESS_ACTION, 'om-system'))
141
+ if (includeManageActions) {
142
+ entries.push(buildCatalogEntry(path, 'manage', 'om-system'))
143
+ }
144
+ }
145
+
146
+ for (const { key, rolePermission } of PERMISSION_ACCESS_KEY_DEFINITIONS) {
147
+ entries.push({
148
+ key,
149
+ source: 'permission',
150
+ rolePermission
151
+ })
152
+ }
153
+
154
+ for (const systemPath of diagnosticKeys) {
155
+ entries.push(buildCatalogEntry(systemPath, DEFAULT_ACCESS_ACTION, 'diagnostic'))
156
+ }
157
+
158
+ return {
159
+ bySystemPath: groupCatalogEntries(entries),
160
+ entries
161
+ }
162
+ }
163
+
164
+ export function findAccessCatalogEntry(
165
+ catalog: AccessKeyCatalog,
166
+ key: NormalizedAccessKey
167
+ ): AccessCatalogEntry | undefined {
168
+ return catalog.bySystemPath.get(key.systemPath)?.find((entry) => entry.key.action === key.action)
169
+ }
170
+
171
+ export function listAccessKeys(catalog: AccessKeyCatalog): NormalizedAccessKey[] {
172
+ return catalog.entries.map((entry) => entry.key)
173
+ }
@@ -0,0 +1,185 @@
1
+ import {
2
+ DEFAULT_ACCESS_ACTION,
3
+ PLATFORM_ADMIN_ACCESS_KEY,
4
+ deriveAccessKeyCatalog,
5
+ findAccessCatalogEntry,
6
+ normalizeAccessKey,
7
+ type AccessKeyCatalog,
8
+ type AccessKeyInput,
9
+ type NormalizedAccessKey
10
+ } from './access-keys'
11
+ import { getSystem } from '../organization-model/helpers'
12
+ import type { OrganizationModel, OrganizationModelSystemLifecycle } from '../organization-model/types'
13
+
14
+ export type AccessRestrictedBy =
15
+ | 'catalog'
16
+ | 'membership'
17
+ | 'system-lifecycle'
18
+ | 'role-permission'
19
+ | 'diagnostic-allowlist'
20
+ | null
21
+
22
+ export type AccessReason =
23
+ | 'allowed'
24
+ | 'platform-admin-bypass'
25
+ | 'invalid-access-key'
26
+ | 'unknown-access-key'
27
+ | 'organization-mismatch'
28
+ | 'missing-membership'
29
+ | 'system-not-active'
30
+ | 'role-permission-denied'
31
+ | 'diagnostic-key-not-allowed'
32
+
33
+ export interface AccessAnswer {
34
+ allowed: boolean
35
+ restrictedBy: AccessRestrictedBy
36
+ reason: AccessReason
37
+ }
38
+
39
+ export interface AccessContextMembership {
40
+ id?: string
41
+ organizationId: string
42
+ role?: string | null
43
+ effectivePermissions?: readonly string[] | null
44
+ }
45
+
46
+ export interface AccessContextProfile {
47
+ id?: string
48
+ isPlatformAdmin?: boolean | null
49
+ is_platform_admin?: boolean | null
50
+ }
51
+
52
+ export interface AccessContext {
53
+ organizationId: string
54
+ organizationModel: OrganizationModel
55
+ membership?: AccessContextMembership | null
56
+ profile?: AccessContextProfile | null
57
+ permissions?: readonly string[] | null
58
+ diagnosticAllowlist?: ReadonlySet<string> | readonly string[]
59
+ accessCatalog?: AccessKeyCatalog
60
+ betaAccessEnabled?: boolean
61
+ isDevelopment?: boolean
62
+ }
63
+
64
+ export interface AccessModel {
65
+ catalog: AccessKeyCatalog
66
+ checkAccess: (key: AccessKeyInput, ctx: Omit<AccessContext, 'accessCatalog'>) => AccessAnswer
67
+ }
68
+
69
+ const ALLOWED: AccessAnswer = { allowed: true, restrictedBy: null, reason: 'allowed' }
70
+ const PLATFORM_ADMIN_BYPASS: AccessAnswer = {
71
+ allowed: true,
72
+ restrictedBy: null,
73
+ reason: 'platform-admin-bypass'
74
+ }
75
+
76
+ function deny(restrictedBy: Exclude<AccessRestrictedBy, null>, reason: Exclude<AccessReason, 'allowed'>): AccessAnswer {
77
+ return { allowed: false, restrictedBy, reason }
78
+ }
79
+
80
+ function isPlatformAdmin(profile: AccessContextProfile | null | undefined): boolean {
81
+ return profile?.isPlatformAdmin === true || profile?.is_platform_admin === true
82
+ }
83
+
84
+ function diagnosticAllowlistHas(
85
+ allowlist: AccessContext['diagnosticAllowlist'],
86
+ systemPath: string
87
+ ): boolean {
88
+ if (allowlist === undefined) return false
89
+ return 'has' in allowlist ? allowlist.has(systemPath) : allowlist.includes(systemPath)
90
+ }
91
+
92
+ function lifecycleAllowsAccess(
93
+ lifecycle: OrganizationModelSystemLifecycle | undefined,
94
+ ctx: Pick<AccessContext, 'betaAccessEnabled' | 'isDevelopment'>
95
+ ): boolean {
96
+ if (lifecycle === 'active') return true
97
+ if (lifecycle === 'beta') return ctx.betaAccessEnabled === true || ctx.isDevelopment === true
98
+ return false
99
+ }
100
+
101
+ function getPermissionSource(ctx: AccessContext): readonly string[] | null | undefined {
102
+ return ctx.permissions ?? ctx.membership?.effectivePermissions
103
+ }
104
+
105
+ function hasRequiredPermission(key: NormalizedAccessKey, rolePermission: string | undefined, ctx: AccessContext): boolean {
106
+ const permissionSource = getPermissionSource(ctx)
107
+ if (permissionSource === undefined || permissionSource === null) return true
108
+
109
+ const requiredPermission = rolePermission ?? `${key.systemPath}.${key.action}`
110
+ return permissionSource.includes(requiredPermission)
111
+ }
112
+
113
+ function hasExplicitRequiredPermission(rolePermission: string | undefined, ctx: AccessContext): boolean {
114
+ const permissionSource = getPermissionSource(ctx)
115
+ return rolePermission !== undefined && permissionSource !== undefined && permissionSource !== null
116
+ ? permissionSource.includes(rolePermission)
117
+ : false
118
+ }
119
+
120
+ export function checkAccess(input: AccessKeyInput, ctx: AccessContext): AccessAnswer {
121
+ if (isPlatformAdmin(ctx.profile)) return PLATFORM_ADMIN_BYPASS
122
+
123
+ const parsed = (() => {
124
+ try {
125
+ return normalizeAccessKey(input)
126
+ } catch {
127
+ return null
128
+ }
129
+ })()
130
+
131
+ if (parsed === null) return deny('catalog', 'invalid-access-key')
132
+
133
+ const catalog = ctx.accessCatalog ?? deriveAccessKeyCatalog(ctx.organizationModel)
134
+ const catalogEntry = findAccessCatalogEntry(catalog, parsed)
135
+
136
+ if (catalogEntry === undefined) return deny('catalog', 'unknown-access-key')
137
+
138
+ const membership = ctx.membership
139
+ if (membership === undefined || membership === null) return deny('membership', 'missing-membership')
140
+ if (membership.organizationId !== ctx.organizationId) return deny('membership', 'organization-mismatch')
141
+
142
+ if (parsed.systemPath === PLATFORM_ADMIN_ACCESS_KEY) {
143
+ return deny('role-permission', 'role-permission-denied')
144
+ }
145
+
146
+ if (catalogEntry.source === 'diagnostic') {
147
+ if (!diagnosticAllowlistHas(ctx.diagnosticAllowlist, parsed.systemPath)) {
148
+ return deny('diagnostic-allowlist', 'diagnostic-key-not-allowed')
149
+ }
150
+
151
+ if (parsed.action !== DEFAULT_ACCESS_ACTION && !hasRequiredPermission(parsed, catalogEntry.rolePermission, ctx)) {
152
+ return deny('role-permission', 'role-permission-denied')
153
+ }
154
+
155
+ return ALLOWED
156
+ }
157
+
158
+ if (catalogEntry.source === 'permission') {
159
+ if (!hasExplicitRequiredPermission(catalogEntry.rolePermission, ctx)) {
160
+ return deny('role-permission', 'role-permission-denied')
161
+ }
162
+
163
+ return ALLOWED
164
+ }
165
+
166
+ const system = getSystem(ctx.organizationModel, parsed.systemPath)
167
+ if (system === undefined || !lifecycleAllowsAccess(system.lifecycle, ctx)) {
168
+ return deny('system-lifecycle', 'system-not-active')
169
+ }
170
+
171
+ if (parsed.action !== DEFAULT_ACCESS_ACTION && !hasRequiredPermission(parsed, catalogEntry.rolePermission, ctx)) {
172
+ return deny('role-permission', 'role-permission-denied')
173
+ }
174
+
175
+ return ALLOWED
176
+ }
177
+
178
+ export function createAccessModel(organizationModel: OrganizationModel): AccessModel {
179
+ const catalog = deriveAccessKeyCatalog(organizationModel)
180
+
181
+ return {
182
+ catalog,
183
+ checkAccess: (key, ctx) => checkAccess(key, { ...ctx, organizationModel, accessCatalog: catalog })
184
+ }
185
+ }
package/src/auth/index.ts CHANGED
@@ -4,5 +4,9 @@
4
4
  * Identity and multi-tenancy types
5
5
  */
6
6
 
7
- // Multi-tenancy types (browser-safe only)
8
- export * from './multi-tenancy/index'
7
+ // Multi-tenancy types (browser-safe only)
8
+ export * from './multi-tenancy/index'
9
+
10
+ // Unified access model primitives (browser-safe only)
11
+ export * from './access-keys'
12
+ export * from './access-model'
@@ -1,5 +1,4 @@
1
- import type { MembershipFeatureConfig } from '../types'
2
- import { type MembershipStatus } from './api-schemas.js'
1
+ import { type MembershipStatus } from './api-schemas.js'
3
2
 
4
3
  export type { MembershipStatus }
5
4
 
@@ -75,8 +74,7 @@ export interface MembershipWithDetails extends OrganizationMembership {
75
74
  metadata?: Record<string, unknown>
76
75
  config?: Record<string, unknown>
77
76
  }
78
- config?: MembershipFeatureConfig
79
- }
77
+ }
80
78
 
81
79
  /**
82
80
  * UI-specific membership table row data
@@ -4,7 +4,7 @@
4
4
  * Source of truth for the permission keys used by:
5
5
  * - RLS policies in Supabase (via has_org_permission(org_id, key))
6
6
  * - API middleware (via requireOrganizationPermission(key))
7
- * - UI hooks (via useOrganizationPermissions().hasPermission(key))
7
+ * - UI access checks (via useAccess() permission-backed AccessKeys)
8
8
  *
9
9
  * The DB table `org_rol_permissions` mirrors this constant. There is no
10
10
  * runtime reconciler; parity is enforced two ways:
@@ -3,23 +3,14 @@
3
3
  *
4
4
  * Config is stored in dedicated `config` columns (NOT nested in metadata):
5
5
  * - organizations.config: Org-level config (no feature toggles -- all features available by default)
6
- * - org_memberships.config: Per-user-per-org feature overrides
6
+ * - org_memberships.config: Reserved for non-access membership configuration
7
7
  * - users.config: User-global config
8
8
  */
9
9
 
10
10
  import type { ThemePresetName } from './theme-presets'
11
11
 
12
- /**
13
- * Per-user-per-org config (stored in org_memberships.config)
14
- * Controls which features a specific member can access within their org.
15
- * Keys are feature IDs from the organization model (e.g. crm, lead-gen, projects, seo).
16
- */
17
- export interface MembershipFeatureConfig {
18
- features?: Record<string, boolean>
19
- }
20
-
21
- /**
22
- * User-global config (stored in users.config)
12
+ /**
13
+ * User-global config (stored in users.config)
23
14
  * Theme and onboarding are user-specific, NOT org-specific
24
15
  */
25
16
  export interface UserConfig {
@@ -1,9 +1,73 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import {
3
- LEAD_GEN_PIPELINE_DEFINITIONS,
4
- type CrmPriorityRuleConfig,
5
- type StatefulPipelineDefinition
6
- } from '../../organization-model/domains/sales'
2
+ import { type CrmPriorityRuleConfig, type StatefulPipelineDefinition } from '../../organization-model/domains/sales'
3
+
4
+ // Inline fixture for lead-gen pipeline stage/state validation tests.
5
+ // The canonical constants live in @repo/elevasis-core; @repo/core cannot depend on it.
6
+ const LEAD_GEN_PIPELINE_DEFINITIONS: Record<string, StatefulPipelineDefinition[]> = {
7
+ 'acq.list-member': [
8
+ {
9
+ pipelineKey: 'lead-gen',
10
+ label: 'Lead Generation',
11
+ entityKey: 'acq.list-member',
12
+ stages: [
13
+ {
14
+ stageKey: 'outreach',
15
+ label: 'Outreach',
16
+ states: [
17
+ { stateKey: 'pending', label: 'Pending' },
18
+ { stateKey: 'personalized', label: 'Personalized' },
19
+ { stateKey: 'uploaded', label: 'Uploaded' },
20
+ { stateKey: 'interested', label: 'Interested' }
21
+ ]
22
+ },
23
+ {
24
+ stageKey: 'prospecting',
25
+ label: 'Prospecting',
26
+ states: [
27
+ { stateKey: 'pending', label: 'Pending' },
28
+ { stateKey: 'discovered', label: 'Discovered' },
29
+ { stateKey: 'verified', label: 'Verified' }
30
+ ]
31
+ },
32
+ { stageKey: 'qualification', label: 'Qualification', states: [{ stateKey: 'pending', label: 'Pending' }] }
33
+ ]
34
+ }
35
+ ],
36
+ 'acq.list-company': [
37
+ {
38
+ pipelineKey: 'lead-gen',
39
+ label: 'Lead Generation',
40
+ entityKey: 'acq.list-company',
41
+ stages: [
42
+ {
43
+ stageKey: 'outreach',
44
+ label: 'Outreach',
45
+ states: [
46
+ { stateKey: 'pending', label: 'Pending' },
47
+ { stateKey: 'uploaded', label: 'Uploaded' }
48
+ ]
49
+ },
50
+ {
51
+ stageKey: 'prospecting',
52
+ label: 'Prospecting',
53
+ states: [
54
+ { stateKey: 'pending', label: 'Pending' },
55
+ { stateKey: 'populated', label: 'Populated' },
56
+ { stateKey: 'extracted', label: 'Extracted' }
57
+ ]
58
+ },
59
+ {
60
+ stageKey: 'qualification',
61
+ label: 'Qualification',
62
+ states: [
63
+ { stateKey: 'pending', label: 'Pending' },
64
+ { stateKey: 'qualified', label: 'Qualified' }
65
+ ]
66
+ }
67
+ ]
68
+ }
69
+ ]
70
+ }
7
71
  import { DEFAULT_ORGANIZATION_MODEL } from '../../organization-model/defaults'
8
72
  import type { OrganizationModel } from '../../organization-model/types'
9
73
  import { CrmPriorityOverrideSchema, evaluateCrmDealPriority, resolveCrmPriorityRuleConfig } from './crm-priority'
@@ -1,8 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import {
3
- LEAD_GEN_PIPELINE_DEFINITIONS,
4
- type StatefulPipelineDefinition
5
- } from '../../organization-model/domains/sales'
2
+ import { type StatefulPipelineDefinition } from '../../organization-model/domains/sales'
6
3
  import { ActivityEventSchema } from './activity-events'
7
4
  import { DealStageSchema, TransitionItemRequestSchema } from './api-schemas'
8
5
  import { deriveActions } from './derive-actions'
@@ -188,8 +185,29 @@ describe('CRM stage and transition vocabulary contracts', () => {
188
185
  }
189
186
  })
190
187
 
191
- it('accepts canonical lead-gen stage/state pairs in transition requests', () => {
192
- for (const pipelineDefinitions of Object.values(LEAD_GEN_PIPELINE_DEFINITIONS)) {
188
+ it('accepts caller-supplied lead-gen stage/state pairs in transition requests', () => {
189
+ const leadGenPipelines: Record<string, StatefulPipelineDefinition[]> = {
190
+ 'acq.list-member': [
191
+ {
192
+ pipelineKey: 'lead-gen',
193
+ label: 'Lead Gen',
194
+ entityKey: 'leadgen.contact',
195
+ stages: [
196
+ {
197
+ stageKey: 'outreach',
198
+ label: 'Outreach',
199
+ color: 'blue',
200
+ states: [
201
+ { stateKey: 'personalized', label: 'Personalized' },
202
+ { stateKey: 'contacted', label: 'Contacted' }
203
+ ]
204
+ }
205
+ ]
206
+ }
207
+ ]
208
+ }
209
+
210
+ for (const pipelineDefinitions of Object.values(leadGenPipelines)) {
193
211
  for (const pipeline of pipelineDefinitions) {
194
212
  for (const stage of pipeline.stages) {
195
213
  for (const state of stage.states) {