@elevasis/core 0.2.1 → 0.4.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 (45) hide show
  1. package/dist/index.d.ts +63 -103
  2. package/dist/index.js +431 -111
  3. package/dist/organization-model/index.d.ts +63 -103
  4. package/dist/organization-model/index.js +431 -111
  5. package/package.json +1 -1
  6. package/src/README.md +1 -1
  7. package/src/__tests__/template-foundations-compatibility.test.ts +28 -36
  8. package/src/auth/multi-tenancy/types.ts +4 -11
  9. package/src/auth/multi-tenancy/users/api-schemas.ts +1 -1
  10. package/src/business/base-entities.test.ts +481 -0
  11. package/src/business/base-entities.ts +241 -0
  12. package/src/business/delivery/types.ts +1 -1
  13. package/src/business/index.ts +3 -0
  14. package/src/execution/index.ts +3 -6
  15. package/src/index.ts +1 -1
  16. package/src/organization-model/README.md +25 -26
  17. package/src/organization-model/__tests__/graph.test.ts +103 -71
  18. package/src/organization-model/__tests__/resolve.test.ts +22 -31
  19. package/src/organization-model/contracts.ts +3 -0
  20. package/src/organization-model/defaults.ts +59 -7
  21. package/src/organization-model/domains/features.ts +19 -54
  22. package/src/organization-model/domains/navigation.ts +266 -17
  23. package/src/organization-model/domains/shared.ts +1 -10
  24. package/src/organization-model/foundation.ts +97 -0
  25. package/src/organization-model/graph/build.ts +34 -67
  26. package/src/organization-model/graph/schema.ts +2 -4
  27. package/src/organization-model/graph/types.ts +3 -15
  28. package/src/organization-model/index.ts +2 -0
  29. package/src/organization-model/organization-graph.mdx +37 -28
  30. package/src/organization-model/organization-model.mdx +34 -36
  31. package/src/organization-model/published.ts +12 -3
  32. package/src/organization-model/schema.ts +38 -34
  33. package/src/organization-model/types.ts +5 -10
  34. package/src/platform/constants/versions.ts +1 -1
  35. package/src/platform/sse/events.ts +1 -34
  36. package/src/projects/api-schemas.ts +2 -1
  37. package/src/reference/_generated/contracts.md +10 -31
  38. package/src/reference/glossary.md +14 -18
  39. package/src/supabase/database.types.ts +0 -107
  40. package/src/test-utils/rls/RLSTestContext.ts +1 -31
  41. package/src/execution/calibration/__tests__/schemas.test.ts +0 -320
  42. package/src/execution/calibration/index.ts +0 -3
  43. package/src/execution/calibration/schemas.ts +0 -121
  44. package/src/execution/calibration/sse-events.ts +0 -125
  45. package/src/execution/calibration/types.ts +0 -190
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elevasis/core",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "description": "Minimal shared constants across Elevasis monorepo",
6
6
  "sideEffects": false,
package/src/README.md CHANGED
@@ -23,7 +23,7 @@ Within the monorepo, the internal `@repo/core` package exposes additional subpat
23
23
  - `@repo/core/server` - Node.js-only helpers and services.
24
24
  - `@repo/core/platform` - shared constants, utilities, registry, SSE, and API types.
25
25
  - `@repo/core/auth` - multi-tenancy types for organizations, users, memberships, invitations, and credentials.
26
- - `@repo/core/execution` - workflow, agent, scheduler, calibration, and execution-interface contracts.
26
+ - `@repo/core/execution` - workflow, agent, scheduler, and execution-interface contracts.
27
27
  - `@repo/core/commands` - command queue types and schemas.
28
28
  - `@repo/core/operations` - sessions, notifications, observability, activities, triggers, and debug logs.
29
29
  - `@repo/core/supabase` - generated database types and helpers.
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { DELIVERY_PROJECTS_VIEW_CAPABILITY_ID, PROJECTS_FEATURE_ID } from '../organization-model/published'
2
3
  import {
3
4
  getOrganizationSurface,
4
5
  homeLabel,
@@ -14,18 +15,18 @@ describe('template foundations compatibility', () => {
14
15
  expect(foundationOrganizationModel.navigation.defaultSurfaceId).toBe('operations')
15
16
  expect(getOrganizationSurface('operations')).toMatchObject({
16
17
  id: 'operations',
17
- label: 'Operations',
18
+ label: 'Organization Graph',
18
19
  path: '/operations',
19
20
  surfaceType: 'graph',
20
- featureKey: 'operations'
21
+ featureId: 'operations'
21
22
  })
22
23
 
23
24
  expect(getOrganizationSurface('operations.organization-graph')).toMatchObject({
24
25
  id: 'operations.organization-graph',
25
- label: 'Operations',
26
+ label: 'Organization Graph',
26
27
  path: '/operations',
27
28
  surfaceType: 'graph',
28
- featureKey: 'operations'
29
+ featureId: 'operations'
29
30
  })
30
31
  })
31
32
 
@@ -33,45 +34,41 @@ describe('template foundations compatibility', () => {
33
34
  const legacySurfaces = [
34
35
  {
35
36
  id: 'operations',
36
- label: 'Operations',
37
+ label: 'Organization Graph',
37
38
  surfaceType: 'graph',
38
- expectedFeatureKey: 'operations',
39
+ expectedFeatureId: 'operations',
39
40
  expectedIcon: 'operations',
40
41
  expectedPath: '/operations',
41
- expectedDomainIds: ['operations'],
42
42
  expectedEntityIds: [],
43
- expectedCapabilityIds: ['operations.organization-graph', 'operations.command-view']
43
+ expectedCapabilityIds: ['operations.organization-graph']
44
44
  },
45
45
  {
46
- id: 'projects',
46
+ id: PROJECTS_FEATURE_ID,
47
47
  label: 'Projects',
48
48
  surfaceType: 'list',
49
- expectedFeatureKey: 'projects',
49
+ expectedFeatureId: PROJECTS_FEATURE_ID,
50
50
  expectedIcon: 'projects',
51
51
  expectedPath: '/projects',
52
- expectedDomainIds: ['delivery'],
53
52
  expectedEntityIds: ['delivery.project'],
54
- expectedCapabilityIds: ['delivery.projects.view']
53
+ expectedCapabilityIds: [DELIVERY_PROJECTS_VIEW_CAPABILITY_ID]
55
54
  },
56
55
  {
57
56
  id: 'lead-gen',
58
- label: 'Lead Gen',
57
+ label: 'Lists',
59
58
  surfaceType: 'list',
60
- expectedFeatureKey: 'lead-gen',
59
+ expectedFeatureId: 'lead-gen',
61
60
  expectedIcon: 'lead-gen',
62
- expectedPath: '/lead-gen',
63
- expectedDomainIds: ['lead-gen'],
61
+ expectedPath: '/lead-gen/lists',
64
62
  expectedEntityIds: ['leadgen.list'],
65
63
  expectedCapabilityIds: ['leadgen.lists.manage']
66
64
  },
67
65
  {
68
66
  id: 'crm',
69
- label: 'CRM',
67
+ label: 'Pipeline',
70
68
  surfaceType: 'graph',
71
- expectedFeatureKey: 'crm',
69
+ expectedFeatureId: 'crm',
72
70
  expectedIcon: 'crm',
73
- expectedPath: '/crm',
74
- expectedDomainIds: ['crm'],
71
+ expectedPath: '/crm/pipeline',
75
72
  expectedEntityIds: ['crm.deal'],
76
73
  expectedCapabilityIds: ['crm.pipeline.manage']
77
74
  }
@@ -86,8 +83,7 @@ describe('template foundations compatibility', () => {
86
83
  path: surface.expectedPath,
87
84
  surfaceType: surface.surfaceType,
88
85
  icon: surface.expectedIcon,
89
- featureKey: surface.expectedFeatureKey,
90
- domainIds: surface.expectedDomainIds,
86
+ featureId: surface.expectedFeatureId,
91
87
  entityIds: surface.expectedEntityIds,
92
88
  resourceIds: [],
93
89
  capabilityIds: surface.expectedCapabilityIds
@@ -96,20 +92,16 @@ describe('template foundations compatibility', () => {
96
92
  })
97
93
 
98
94
  it('maps grouped core features onto the legacy template feature vocabulary', () => {
99
- expect(foundationOrganizationModel.features.enabled.acquisition).toBe(true)
100
- expect(foundationOrganizationModel.features.enabled.delivery).toBe(true)
101
- expect(foundationOrganizationModel.features.enabled.crm).toBe(
102
- foundationOrganizationModel.features.enabled.acquisition
103
- )
104
- expect(foundationOrganizationModel.features.enabled['lead-gen']).toBe(
105
- foundationOrganizationModel.features.enabled.acquisition
106
- )
107
- expect(foundationOrganizationModel.features.enabled.projects).toBe(
108
- foundationOrganizationModel.features.enabled.delivery
109
- )
95
+ const features = foundationOrganizationModel.features as Array<{ id: string; label: string; enabled: boolean }>
96
+ const featureById = (id: string) => features.find((f) => f.id === id)
110
97
 
111
- expect(foundationOrganizationModel.features.labels.crm).toBe('CRM')
112
- expect(foundationOrganizationModel.features.labels['lead-gen']).toBe('Lead Gen')
113
- expect(foundationOrganizationModel.features.labels.projects).toBe('Projects')
98
+ expect(featureById('crm')?.enabled).toBe(true)
99
+ expect(featureById('lead-gen')?.enabled).toBe(true)
100
+ expect(featureById('projects')?.enabled).toBe(true)
101
+ expect(featureById('operations')?.enabled).toBe(true)
102
+
103
+ expect(featureById('crm')?.label).toBe('CRM')
104
+ expect(featureById('lead-gen')?.label).toBe('Lead Gen')
105
+ expect(featureById('projects')?.label).toBe('Projects')
114
106
  })
115
107
  })
@@ -9,18 +9,11 @@
9
9
 
10
10
  /**
11
11
  * Per-user-per-org config (stored in org_memberships.config)
12
- * Controls which features a specific member can access within their org
13
- * Valid feature keys: operations, monitoring, acquisition, delivery, calibration, seo
12
+ * Controls which features a specific member can access within their org.
13
+ * Keys are feature IDs from the organization model (e.g. crm, lead-gen, projects, seo).
14
14
  */
15
15
  export interface MembershipFeatureConfig {
16
- features?: {
17
- operations?: boolean
18
- monitoring?: boolean
19
- acquisition?: boolean
20
- delivery?: boolean
21
- calibration?: boolean
22
- seo?: boolean
23
- }
16
+ features?: Record<string, boolean>
24
17
  }
25
18
 
26
19
  /**
@@ -37,7 +30,7 @@ export interface UserConfig {
37
30
  | 'aurora'
38
31
  | 'rose-gold'
39
32
  | 'midnight'
40
- | 'ember'
33
+ | 'titanium'
41
34
  | 'obsidian'
42
35
  | 'honey'
43
36
  | 'abyss'
@@ -103,7 +103,7 @@ export const UpdateMyProfileSchema = z
103
103
  'aurora',
104
104
  'rose-gold',
105
105
  'midnight',
106
- 'ember',
106
+ 'titanium',
107
107
  'obsidian',
108
108
  'honey',
109
109
  'abyss',
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Base Entity Contracts - Unit Tests
3
+ *
4
+ * Verifies that:
5
+ * - Base interfaces accept generic TMeta narrowing
6
+ * - Zod schemas parse valid data correctly
7
+ * - Schema extension via .extend() works for client metadata narrowing
8
+ * - Default metadata falls back to Record<string, unknown>
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest'
12
+ import { z } from 'zod'
13
+ import {
14
+ BaseProjectSchema,
15
+ BaseMilestoneSchema,
16
+ BaseTaskSchema,
17
+ BaseDealSchema,
18
+ BaseCompanySchema,
19
+ BaseContactSchema,
20
+ type BaseProject,
21
+ type BaseMilestone,
22
+ type BaseTask,
23
+ type BaseDeal,
24
+ type BaseCompany,
25
+ type BaseContact
26
+ } from './base-entities'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Fixtures
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const ORG_ID = '00000000-0000-0000-0000-000000000001'
33
+ const TIMESTAMPS = {
34
+ createdAt: '2026-04-17T00:00:00.000Z',
35
+ updatedAt: '2026-04-17T00:00:00.000Z'
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // BaseProjectSchema
40
+ // ---------------------------------------------------------------------------
41
+
42
+ describe('BaseProjectSchema', () => {
43
+ const validProject = {
44
+ id: '00000000-0000-0000-0000-000000000010',
45
+ organizationId: ORG_ID,
46
+ name: 'Website Rebuild',
47
+ kind: 'client_engagement',
48
+ status: 'active',
49
+ description: 'Rebuild the client website.',
50
+ metadata: { budget: 50000 },
51
+ ...TIMESTAMPS
52
+ }
53
+
54
+ it('parses a valid project', () => {
55
+ const result = BaseProjectSchema.safeParse(validProject)
56
+ expect(result.success).toBe(true)
57
+ if (result.success) {
58
+ expect(result.data.name).toBe('Website Rebuild')
59
+ expect(result.data.kind).toBe('client_engagement')
60
+ expect(result.data.status).toBe('active')
61
+ }
62
+ })
63
+
64
+ it('accepts null description', () => {
65
+ const result = BaseProjectSchema.safeParse({ ...validProject, description: null })
66
+ expect(result.success).toBe(true)
67
+ })
68
+
69
+ it('defaults metadata to empty object when omitted', () => {
70
+ const { metadata: _meta, ...withoutMeta } = validProject
71
+ const result = BaseProjectSchema.safeParse(withoutMeta)
72
+ expect(result.success).toBe(true)
73
+ if (result.success) {
74
+ expect(result.data.metadata).toEqual({})
75
+ }
76
+ })
77
+
78
+ it('rejects missing required fields', () => {
79
+ const result = BaseProjectSchema.safeParse({ id: 'x', organizationId: ORG_ID })
80
+ expect(result.success).toBe(false)
81
+ })
82
+
83
+ it('supports schema extension for narrowed metadata', () => {
84
+ const ProjectMetaSchema = z.object({ budget: z.number(), riskScore: z.enum(['low', 'medium', 'high']) })
85
+ const NarrowedProjectSchema = BaseProjectSchema.extend({ metadata: ProjectMetaSchema })
86
+
87
+ const result = NarrowedProjectSchema.safeParse({
88
+ ...validProject,
89
+ metadata: { budget: 75000, riskScore: 'low' }
90
+ })
91
+ expect(result.success).toBe(true)
92
+ if (result.success) {
93
+ expect(result.data.metadata.budget).toBe(75000)
94
+ expect(result.data.metadata.riskScore).toBe('low')
95
+ }
96
+ })
97
+
98
+ it('rejects invalid narrowed metadata via extended schema', () => {
99
+ const ProjectMetaSchema = z.object({ budget: z.number() })
100
+ const NarrowedProjectSchema = BaseProjectSchema.extend({ metadata: ProjectMetaSchema })
101
+
102
+ const result = NarrowedProjectSchema.safeParse({
103
+ ...validProject,
104
+ metadata: { budget: 'not-a-number' }
105
+ })
106
+ expect(result.success).toBe(false)
107
+ })
108
+ })
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // BaseMilestoneSchema
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe('BaseMilestoneSchema', () => {
115
+ const validMilestone = {
116
+ id: '00000000-0000-0000-0000-000000000020',
117
+ organizationId: ORG_ID,
118
+ projectId: '00000000-0000-0000-0000-000000000010',
119
+ name: 'Phase 1 Complete',
120
+ status: 'upcoming',
121
+ description: null,
122
+ metadata: {},
123
+ ...TIMESTAMPS
124
+ }
125
+
126
+ it('parses a valid milestone', () => {
127
+ const result = BaseMilestoneSchema.safeParse(validMilestone)
128
+ expect(result.success).toBe(true)
129
+ if (result.success) {
130
+ expect(result.data.projectId).toBe('00000000-0000-0000-0000-000000000010')
131
+ expect(result.data.status).toBe('upcoming')
132
+ }
133
+ })
134
+
135
+ it('defaults metadata to empty object when omitted', () => {
136
+ const { metadata: _meta, ...withoutMeta } = validMilestone
137
+ const result = BaseMilestoneSchema.safeParse(withoutMeta)
138
+ expect(result.success).toBe(true)
139
+ if (result.success) {
140
+ expect(result.data.metadata).toEqual({})
141
+ }
142
+ })
143
+
144
+ it('supports schema extension for narrowed metadata', () => {
145
+ const MilestoneMetaSchema = z.object({ deliverables: z.array(z.string()) })
146
+ const NarrowedSchema = BaseMilestoneSchema.extend({ metadata: MilestoneMetaSchema })
147
+
148
+ const result = NarrowedSchema.safeParse({
149
+ ...validMilestone,
150
+ metadata: { deliverables: ['doc-1', 'doc-2'] }
151
+ })
152
+ expect(result.success).toBe(true)
153
+ if (result.success) {
154
+ expect(result.data.metadata.deliverables).toHaveLength(2)
155
+ }
156
+ })
157
+ })
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // BaseTaskSchema
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe('BaseTaskSchema', () => {
164
+ const validTask = {
165
+ id: '00000000-0000-0000-0000-000000000030',
166
+ organizationId: ORG_ID,
167
+ projectId: '00000000-0000-0000-0000-000000000010',
168
+ name: 'Write API docs',
169
+ status: 'planned',
170
+ type: 'documentation',
171
+ description: 'Document all endpoints.',
172
+ metadata: { priority: 'high' },
173
+ ...TIMESTAMPS
174
+ }
175
+
176
+ it('parses a valid task', () => {
177
+ const result = BaseTaskSchema.safeParse(validTask)
178
+ expect(result.success).toBe(true)
179
+ if (result.success) {
180
+ expect(result.data.type).toBe('documentation')
181
+ expect(result.data.status).toBe('planned')
182
+ }
183
+ })
184
+
185
+ it('accepts null description', () => {
186
+ const result = BaseTaskSchema.safeParse({ ...validTask, description: null })
187
+ expect(result.success).toBe(true)
188
+ })
189
+
190
+ it('supports schema extension for narrowed metadata', () => {
191
+ const TaskMetaSchema = z.object({ storyPoints: z.number().int(), sprint: z.string().optional() })
192
+ const NarrowedSchema = BaseTaskSchema.extend({ metadata: TaskMetaSchema })
193
+
194
+ const result = NarrowedSchema.safeParse({
195
+ ...validTask,
196
+ metadata: { storyPoints: 3, sprint: 'S4' }
197
+ })
198
+ expect(result.success).toBe(true)
199
+ if (result.success) {
200
+ expect(result.data.metadata.storyPoints).toBe(3)
201
+ expect(result.data.metadata.sprint).toBe('S4')
202
+ }
203
+ })
204
+ })
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // BaseDealSchema
208
+ // ---------------------------------------------------------------------------
209
+
210
+ describe('BaseDealSchema', () => {
211
+ const validDeal = {
212
+ id: '00000000-0000-0000-0000-000000000040',
213
+ organizationId: ORG_ID,
214
+ contactEmail: 'prospect@example.com',
215
+ stage: 'proposal',
216
+ metadata: {},
217
+ ...TIMESTAMPS
218
+ }
219
+
220
+ it('parses a valid deal', () => {
221
+ const result = BaseDealSchema.safeParse(validDeal)
222
+ expect(result.success).toBe(true)
223
+ if (result.success) {
224
+ expect(result.data.contactEmail).toBe('prospect@example.com')
225
+ expect(result.data.stage).toBe('proposal')
226
+ }
227
+ })
228
+
229
+ it('accepts null stage', () => {
230
+ const result = BaseDealSchema.safeParse({ ...validDeal, stage: null })
231
+ expect(result.success).toBe(true)
232
+ if (result.success) {
233
+ expect(result.data.stage).toBeNull()
234
+ }
235
+ })
236
+
237
+ it('defaults metadata to empty object when omitted', () => {
238
+ const { metadata: _meta, ...withoutMeta } = validDeal
239
+ const result = BaseDealSchema.safeParse(withoutMeta)
240
+ expect(result.success).toBe(true)
241
+ if (result.success) {
242
+ expect(result.data.metadata).toEqual({})
243
+ }
244
+ })
245
+
246
+ it('supports schema extension for narrowed metadata', () => {
247
+ const DealMetaSchema = z.object({ initialFee: z.number(), monthlyFee: z.number() })
248
+ const NarrowedSchema = BaseDealSchema.extend({ metadata: DealMetaSchema })
249
+
250
+ const result = NarrowedSchema.safeParse({
251
+ ...validDeal,
252
+ metadata: { initialFee: 2000, monthlyFee: 500 }
253
+ })
254
+ expect(result.success).toBe(true)
255
+ if (result.success) {
256
+ expect(result.data.metadata.initialFee).toBe(2000)
257
+ }
258
+ })
259
+ })
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // BaseCompanySchema
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe('BaseCompanySchema', () => {
266
+ const validCompany = {
267
+ id: '00000000-0000-0000-0000-000000000050',
268
+ organizationId: ORG_ID,
269
+ name: 'Acme Corp',
270
+ domain: 'acme.com',
271
+ status: 'active',
272
+ metadata: {},
273
+ ...TIMESTAMPS
274
+ }
275
+
276
+ it('parses a valid company', () => {
277
+ const result = BaseCompanySchema.safeParse(validCompany)
278
+ expect(result.success).toBe(true)
279
+ if (result.success) {
280
+ expect(result.data.name).toBe('Acme Corp')
281
+ expect(result.data.domain).toBe('acme.com')
282
+ }
283
+ })
284
+
285
+ it('accepts null domain', () => {
286
+ const result = BaseCompanySchema.safeParse({ ...validCompany, domain: null })
287
+ expect(result.success).toBe(true)
288
+ })
289
+
290
+ it('supports schema extension for narrowed metadata', () => {
291
+ const CompanyMetaSchema = z.object({ reviewCount: z.number().int(), rating: z.number() })
292
+ const NarrowedSchema = BaseCompanySchema.extend({ metadata: CompanyMetaSchema })
293
+
294
+ const result = NarrowedSchema.safeParse({
295
+ ...validCompany,
296
+ metadata: { reviewCount: 120, rating: 4.7 }
297
+ })
298
+ expect(result.success).toBe(true)
299
+ if (result.success) {
300
+ expect(result.data.metadata.reviewCount).toBe(120)
301
+ expect(result.data.metadata.rating).toBe(4.7)
302
+ }
303
+ })
304
+ })
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // BaseContactSchema
308
+ // ---------------------------------------------------------------------------
309
+
310
+ describe('BaseContactSchema', () => {
311
+ const validContact = {
312
+ id: '00000000-0000-0000-0000-000000000060',
313
+ organizationId: ORG_ID,
314
+ email: 'jane@example.com',
315
+ firstName: 'Jane',
316
+ lastName: 'Doe',
317
+ status: 'active',
318
+ metadata: {},
319
+ ...TIMESTAMPS
320
+ }
321
+
322
+ it('parses a valid contact', () => {
323
+ const result = BaseContactSchema.safeParse(validContact)
324
+ expect(result.success).toBe(true)
325
+ if (result.success) {
326
+ expect(result.data.email).toBe('jane@example.com')
327
+ expect(result.data.firstName).toBe('Jane')
328
+ }
329
+ })
330
+
331
+ it('accepts null firstName and lastName', () => {
332
+ const result = BaseContactSchema.safeParse({ ...validContact, firstName: null, lastName: null })
333
+ expect(result.success).toBe(true)
334
+ })
335
+
336
+ it('supports schema extension for narrowed metadata', () => {
337
+ const ContactMetaSchema = z.object({ linkedinScore: z.number().optional(), tags: z.array(z.string()) })
338
+ const NarrowedSchema = BaseContactSchema.extend({ metadata: ContactMetaSchema })
339
+
340
+ const result = NarrowedSchema.safeParse({
341
+ ...validContact,
342
+ metadata: { tags: ['enterprise', 'warm-lead'], linkedinScore: 8 }
343
+ })
344
+ expect(result.success).toBe(true)
345
+ if (result.success) {
346
+ expect(result.data.metadata.tags).toContain('enterprise')
347
+ expect(result.data.metadata.linkedinScore).toBe(8)
348
+ }
349
+ })
350
+ })
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // TypeScript generic interface narrowing (compile-time — exercised at runtime)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ describe('BaseProject<TMeta> generic interface', () => {
357
+ it('accepts unnarrowed metadata as Record<string, unknown>', () => {
358
+ const project: BaseProject = {
359
+ id: '00000000-0000-0000-0000-000000000010',
360
+ organizationId: ORG_ID,
361
+ name: 'Generic Project',
362
+ kind: 'internal',
363
+ status: 'active',
364
+ description: null,
365
+ metadata: { anything: true },
366
+ ...TIMESTAMPS
367
+ }
368
+ expect(project.metadata['anything']).toBe(true)
369
+ })
370
+
371
+ it('accepts narrowed metadata type', () => {
372
+ type ProjectMeta = { budget: number; riskScore: 'low' | 'medium' | 'high' }
373
+ const project: BaseProject<ProjectMeta> = {
374
+ id: '00000000-0000-0000-0000-000000000011',
375
+ organizationId: ORG_ID,
376
+ name: 'Budget Project',
377
+ kind: 'client_engagement',
378
+ status: 'on_track',
379
+ description: 'A budgeted project',
380
+ metadata: { budget: 100000, riskScore: 'low' },
381
+ ...TIMESTAMPS
382
+ }
383
+ expect(project.metadata.budget).toBe(100000)
384
+ expect(project.metadata.riskScore).toBe('low')
385
+ })
386
+ })
387
+
388
+ describe('BaseDeal<TMeta> generic interface', () => {
389
+ it('accepts unnarrowed metadata', () => {
390
+ const deal: BaseDeal = {
391
+ id: '00000000-0000-0000-0000-000000000040',
392
+ organizationId: ORG_ID,
393
+ contactEmail: 'test@example.com',
394
+ stage: 'interested',
395
+ metadata: {},
396
+ ...TIMESTAMPS
397
+ }
398
+ expect(deal.stage).toBe('interested')
399
+ })
400
+
401
+ it('accepts narrowed metadata with deal financials', () => {
402
+ type DealMeta = { contractValue: number; currency: string }
403
+ const deal: BaseDeal<DealMeta> = {
404
+ id: '00000000-0000-0000-0000-000000000041',
405
+ organizationId: ORG_ID,
406
+ contactEmail: 'client@bigco.com',
407
+ stage: 'closing',
408
+ metadata: { contractValue: 24000, currency: 'USD' },
409
+ ...TIMESTAMPS
410
+ }
411
+ expect(deal.metadata.contractValue).toBe(24000)
412
+ })
413
+ })
414
+
415
+ describe('BaseMilestone<TMeta> generic interface', () => {
416
+ it('accepts narrowed metadata', () => {
417
+ type MilestoneMeta = { deliverables: string[] }
418
+ const milestone: BaseMilestone<MilestoneMeta> = {
419
+ id: '00000000-0000-0000-0000-000000000020',
420
+ organizationId: ORG_ID,
421
+ projectId: '00000000-0000-0000-0000-000000000010',
422
+ name: 'Launch',
423
+ status: 'in_progress',
424
+ description: null,
425
+ metadata: { deliverables: ['landing-page', 'email-campaign'] },
426
+ ...TIMESTAMPS
427
+ }
428
+ expect(milestone.metadata.deliverables).toHaveLength(2)
429
+ })
430
+ })
431
+
432
+ describe('BaseTask<TMeta> generic interface', () => {
433
+ it('accepts narrowed metadata', () => {
434
+ type TaskMeta = { storyPoints: number }
435
+ const task: BaseTask<TaskMeta> = {
436
+ id: '00000000-0000-0000-0000-000000000030',
437
+ organizationId: ORG_ID,
438
+ projectId: '00000000-0000-0000-0000-000000000010',
439
+ name: 'Deploy to prod',
440
+ status: 'in_progress',
441
+ type: 'code',
442
+ description: null,
443
+ metadata: { storyPoints: 5 },
444
+ ...TIMESTAMPS
445
+ }
446
+ expect(task.metadata.storyPoints).toBe(5)
447
+ })
448
+ })
449
+
450
+ describe('BaseCompany<TMeta> generic interface', () => {
451
+ it('accepts narrowed metadata', () => {
452
+ type CompanyMeta = { verticalScore: number }
453
+ const company: BaseCompany<CompanyMeta> = {
454
+ id: '00000000-0000-0000-0000-000000000050',
455
+ organizationId: ORG_ID,
456
+ name: 'Tech Startup',
457
+ domain: 'techstartup.io',
458
+ status: 'active',
459
+ metadata: { verticalScore: 92 },
460
+ ...TIMESTAMPS
461
+ }
462
+ expect(company.metadata.verticalScore).toBe(92)
463
+ })
464
+ })
465
+
466
+ describe('BaseContact<TMeta> generic interface', () => {
467
+ it('accepts narrowed metadata', () => {
468
+ type ContactMeta = { personalizationScore: number; openingLine: string }
469
+ const contact: BaseContact<ContactMeta> = {
470
+ id: '00000000-0000-0000-0000-000000000060',
471
+ organizationId: ORG_ID,
472
+ email: 'ceo@example.com',
473
+ firstName: 'Alex',
474
+ lastName: 'Smith',
475
+ status: 'active',
476
+ metadata: { personalizationScore: 87, openingLine: 'Loved your post on AI automation.' },
477
+ ...TIMESTAMPS
478
+ }
479
+ expect(contact.metadata.personalizationScore).toBe(87)
480
+ })
481
+ })