@critical-path/core 0.33.0 → 0.34.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.
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { CriticalPathEngine, type DomainEvent } from '../index.js';
3
+
4
+ function record(engine: CriticalPathEngine) {
5
+ const events: DomainEvent[] = [];
6
+ engine.events.subscribe('*', (e) => void events.push(e));
7
+ return events;
8
+ }
9
+
10
+ describe('team, container and iteration events', () => {
11
+ it('publishes updated and deleted events with the previous state', async () => {
12
+ const engine = new CriticalPathEngine();
13
+ const project = await engine.createProject({ name: 'Events' });
14
+ const team = await engine.createTeam({ name: 'Core', memberIds: [] });
15
+ const container = await engine.createContainer({ projectId: project.id, name: 'Epic' });
16
+ const events = record(engine);
17
+ const iteration = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
18
+
19
+ await engine.updateTeam(team.id, { name: 'Platform' });
20
+ await engine.updateContainer(container.id, { name: 'Epic 2' });
21
+ await engine.updateIteration(iteration.id, { goal: 'Ship' });
22
+ await engine.deleteTeam(team.id);
23
+ await engine.deleteContainer(container.id);
24
+ await engine.deleteIteration(iteration.id);
25
+
26
+ expect(events.map((e) => e.name)).toEqual([
27
+ 'iteration.created',
28
+ 'team.updated',
29
+ 'container.updated',
30
+ 'iteration.updated',
31
+ 'team.deleted',
32
+ 'container.deleted',
33
+ 'iteration.deleted'
34
+ ]);
35
+ const teamUpdated = events.find((e) => e.name === 'team.updated')!.payload as any;
36
+ expect(teamUpdated.previous.name).toBe('Core');
37
+ expect(teamUpdated.team.name).toBe('Platform');
38
+ expect(events.find((e) => e.name === 'iteration.deleted')!.payload).toMatchObject({ projectId: project.id, name: 'S1' });
39
+ expect(new Set(events.map((e) => e.id)).size).toBe(events.length);
40
+ });
41
+
42
+ it('routes deletion webhooks to the owning tenant', async () => {
43
+ const delivered: Array<{ url: string; event: string }> = [];
44
+ const engine = new CriticalPathEngine({
45
+ webhookDelivery: {
46
+ fetch: (async (url: string, init: RequestInit) => {
47
+ delivered.push({ url, event: JSON.parse(init.body as string).event });
48
+ return new Response(null, { status: 204 });
49
+ }) as unknown as typeof fetch
50
+ }
51
+ });
52
+ const acme = engine.withActor({ userId: 'a', tenantId: 'acme' });
53
+ const globex = engine.withActor({ userId: 'g', tenantId: 'globex' });
54
+ await acme.createWebhook({ name: 'Acme', url: 'https://hooks.example.com/acme', events: ['team.deleted', 'container.deleted'] });
55
+ await globex.createWebhook({ name: 'Globex', url: 'https://hooks.example.com/globex', events: ['*'] });
56
+
57
+ const project = await acme.createProject({ name: 'Acme' });
58
+ const team = await acme.createTeam({ name: 'Team', memberIds: [] });
59
+ const container = await acme.createContainer({ projectId: project.id, name: 'C' });
60
+ await acme.deleteTeam(team.id);
61
+ await acme.deleteContainer(container.id);
62
+ await engine.webhooks.idle();
63
+
64
+ expect(delivered.map((d) => d.event).sort()).toEqual(['container.deleted', 'team.deleted']);
65
+ expect(delivered.every((d) => d.url.endsWith('/acme'))).toBe(true);
66
+ });
67
+ });
@@ -63,6 +63,14 @@ import {
63
63
  ProjectUpdatedEvent,
64
64
  ProjectDeletedEvent,
65
65
  TaskDependencyRemovedEvent,
66
+ TeamUpdatedEvent,
67
+ TeamDeletedEvent,
68
+ ContainerUpdatedEvent,
69
+ ContainerDeletedEvent,
70
+ IterationCreatedEvent,
71
+ IterationUpdatedEvent,
72
+ IterationDeletedEvent,
73
+ CriticalPathDomainEvent,
66
74
  WorkflowCreatedEvent,
67
75
  WorkflowUpdatedEvent,
68
76
  WorkflowDeletedEvent,
@@ -161,6 +169,15 @@ export class CriticalPathEngine {
161
169
  return scoped;
162
170
  }
163
171
 
172
+ /** Publishes a domain event, filling in its id and timestamp. */
173
+ private async publishEvent<E extends CriticalPathDomainEvent>(event: Omit<E, 'id' | 'occurredAt'>): Promise<void> {
174
+ await this.events.publish({
175
+ ...event,
176
+ id: `evt_${crypto.randomUUID()}`,
177
+ occurredAt: new Date().toISOString()
178
+ } as E);
179
+ }
180
+
164
181
  private actorIdOr(fallback: string): string {
165
182
  return this.actor?.userId ?? fallback;
166
183
  }
@@ -335,7 +352,7 @@ export class CriticalPathEngine {
335
352
  const now = new Date().toISOString();
336
353
 
337
354
  const event: WorkflowCreatedEvent = {
338
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
355
+ id: `evt_${crypto.randomUUID()}`,
339
356
  name: 'workflow.created',
340
357
  aggregateId: created.id,
341
358
  aggregateType: 'Workflow',
@@ -362,7 +379,7 @@ export class CriticalPathEngine {
362
379
  if (updated) {
363
380
  const now = new Date().toISOString();
364
381
  const event: WorkflowUpdatedEvent = {
365
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
382
+ id: `evt_${crypto.randomUUID()}`,
366
383
  name: 'workflow.updated',
367
384
  aggregateId: updated.id,
368
385
  aggregateType: 'Workflow',
@@ -389,7 +406,7 @@ export class CriticalPathEngine {
389
406
  if (deleted) {
390
407
  const now = new Date().toISOString();
391
408
  const event: WorkflowDeletedEvent = {
392
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
409
+ id: `evt_${crypto.randomUUID()}`,
393
410
  name: 'workflow.deleted',
394
411
  aggregateId: id,
395
412
  aggregateType: 'Workflow',
@@ -469,7 +486,7 @@ export class CriticalPathEngine {
469
486
  const now = new Date().toISOString();
470
487
 
471
488
  const event: ProjectCreatedEvent = {
472
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
489
+ id: `evt_${crypto.randomUUID()}`,
473
490
  name: 'project.created',
474
491
  aggregateId: created.id,
475
492
  aggregateType: 'Project',
@@ -499,7 +516,7 @@ export class CriticalPathEngine {
499
516
  if (updated) {
500
517
  const now = new Date().toISOString();
501
518
  const event: ProjectUpdatedEvent = {
502
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
519
+ id: `evt_${crypto.randomUUID()}`,
503
520
  name: 'project.updated',
504
521
  aggregateId: updated.id,
505
522
  aggregateType: 'Project',
@@ -547,7 +564,7 @@ export class CriticalPathEngine {
547
564
  if (deleted) {
548
565
  const now = new Date().toISOString();
549
566
  const event: ProjectDeletedEvent = {
550
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
567
+ id: `evt_${crypto.randomUUID()}`,
551
568
  name: 'project.deleted',
552
569
  aggregateId: id,
553
570
  aggregateType: 'Project',
@@ -688,7 +705,7 @@ export class CriticalPathEngine {
688
705
 
689
706
  // Publish typed Domain Event
690
707
  const event: TaskCreatedEvent = {
691
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
708
+ id: `evt_${crypto.randomUUID()}`,
692
709
  name: 'task.created',
693
710
  aggregateId: created.id,
694
711
  aggregateType: 'Task',
@@ -927,7 +944,7 @@ export class CriticalPathEngine {
927
944
  // Publish typed Domain Events
928
945
  if (isStatusChange) {
929
946
  const statusEvent: TaskStatusChangedEvent = {
930
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
947
+ id: `evt_${crypto.randomUUID()}`,
931
948
  name: 'task.status_changed',
932
949
  aggregateId: updated.id,
933
950
  aggregateType: 'Task',
@@ -952,7 +969,7 @@ export class CriticalPathEngine {
952
969
  }
953
970
  } else {
954
971
  const updateEvent: TaskUpdatedEvent = {
955
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
972
+ id: `evt_${crypto.randomUUID()}`,
956
973
  name: 'task.updated',
957
974
  aggregateId: updated.id,
958
975
  aggregateType: 'Task',
@@ -974,7 +991,7 @@ export class CriticalPathEngine {
974
991
  if (!wasBlocked && isNowBlocked) {
975
992
  const blockedReason = updated.blockedReason ?? null;
976
993
  const blockedEvent: TaskBlockedEvent = {
977
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
994
+ id: `evt_${crypto.randomUUID()}`,
978
995
  name: 'task.blocked',
979
996
  aggregateId: updated.id,
980
997
  aggregateType: 'Task',
@@ -997,7 +1014,7 @@ export class CriticalPathEngine {
997
1014
  });
998
1015
  } else if (wasBlocked && !isNowBlocked) {
999
1016
  const unblockedEvent: TaskUnblockedEvent = {
1000
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1017
+ id: `evt_${crypto.randomUUID()}`,
1001
1018
  name: 'task.unblocked',
1002
1019
  aggregateId: updated.id,
1003
1020
  aggregateType: 'Task',
@@ -1081,7 +1098,7 @@ export class CriticalPathEngine {
1081
1098
 
1082
1099
  const now = new Date().toISOString();
1083
1100
  const unblockedEvent: TaskUnblockedEvent = {
1084
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1101
+ id: `evt_${crypto.randomUUID()}`,
1085
1102
  name: 'task.unblocked',
1086
1103
  aggregateId: downstream.id,
1087
1104
  aggregateType: 'Task',
@@ -1127,7 +1144,7 @@ export class CriticalPathEngine {
1127
1144
  const now = new Date().toISOString();
1128
1145
 
1129
1146
  const event: TaskDeletedEvent = {
1130
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1147
+ id: `evt_${crypto.randomUUID()}`,
1131
1148
  name: 'task.deleted',
1132
1149
  aggregateId: id,
1133
1150
  aggregateType: 'Task',
@@ -1198,7 +1215,7 @@ export class CriticalPathEngine {
1198
1215
  const removed = await this.store.removeDependency(id);
1199
1216
  if (removed) {
1200
1217
  const event: TaskDependencyRemovedEvent = {
1201
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1218
+ id: `evt_${crypto.randomUUID()}`,
1202
1219
  name: 'dependency.removed',
1203
1220
  aggregateId: id,
1204
1221
  aggregateType: 'Dependency',
@@ -1229,7 +1246,7 @@ export class CriticalPathEngine {
1229
1246
  const now = new Date().toISOString();
1230
1247
 
1231
1248
  const event: TaskDependencyAddedEvent = {
1232
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1249
+ id: `evt_${crypto.randomUUID()}`,
1233
1250
  name: 'dependency.added',
1234
1251
  aggregateId: created.id,
1235
1252
  aggregateType: 'Dependency',
@@ -1357,7 +1374,7 @@ export class CriticalPathEngine {
1357
1374
  const now = new Date().toISOString();
1358
1375
 
1359
1376
  const event: TimeLoggedEvent = {
1360
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1377
+ id: `evt_${crypto.randomUUID()}`,
1361
1378
  name: 'time.logged',
1362
1379
  aggregateId: entry.taskId,
1363
1380
  aggregateType: 'Task',
@@ -1407,7 +1424,7 @@ export class CriticalPathEngine {
1407
1424
  const now = new Date().toISOString();
1408
1425
 
1409
1426
  const event: CommentAddedEvent = {
1410
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1427
+ id: `evt_${crypto.randomUUID()}`,
1411
1428
  name: 'comment.created',
1412
1429
  aggregateId: created.id,
1413
1430
  aggregateType: 'Comment',
@@ -1435,7 +1452,7 @@ export class CriticalPathEngine {
1435
1452
 
1436
1453
  const now = new Date().toISOString();
1437
1454
  const event: CommentUpdatedEvent = {
1438
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1455
+ id: `evt_${crypto.randomUUID()}`,
1439
1456
  name: 'comment.updated',
1440
1457
  aggregateId: updated.id,
1441
1458
  aggregateType: 'Comment',
@@ -1459,7 +1476,7 @@ export class CriticalPathEngine {
1459
1476
  if (deleted) {
1460
1477
  const now = new Date().toISOString();
1461
1478
  const event: CommentDeletedEvent = {
1462
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1479
+ id: `evt_${crypto.randomUUID()}`,
1463
1480
  name: 'comment.deleted',
1464
1481
  aggregateId: id,
1465
1482
  aggregateType: 'Comment',
@@ -1498,7 +1515,7 @@ export class CriticalPathEngine {
1498
1515
 
1499
1516
  const now = new Date().toISOString();
1500
1517
  const event: CommentReactionAddedEvent = {
1501
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1518
+ id: `evt_${crypto.randomUUID()}`,
1502
1519
  name: 'comment.reaction.added',
1503
1520
  aggregateId: commentId,
1504
1521
  aggregateType: 'Comment',
@@ -1538,7 +1555,7 @@ export class CriticalPathEngine {
1538
1555
 
1539
1556
  const now = new Date().toISOString();
1540
1557
  const event: CommentReactionRemovedEvent = {
1541
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1558
+ id: `evt_${crypto.randomUUID()}`,
1542
1559
  name: 'comment.reaction.removed',
1543
1560
  aggregateId: commentId,
1544
1561
  aggregateType: 'Comment',
@@ -1587,7 +1604,7 @@ export class CriticalPathEngine {
1587
1604
  const now = new Date().toISOString();
1588
1605
 
1589
1606
  const event: AttachmentCreatedEvent = {
1590
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1607
+ id: `evt_${crypto.randomUUID()}`,
1591
1608
  name: 'attachment.created',
1592
1609
  aggregateId: created.id,
1593
1610
  aggregateType: 'Attachment',
@@ -1619,7 +1636,7 @@ export class CriticalPathEngine {
1619
1636
  if (deleted) {
1620
1637
  const now = new Date().toISOString();
1621
1638
  const event: AttachmentDeletedEvent = {
1622
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1639
+ id: `evt_${crypto.randomUUID()}`,
1623
1640
  name: 'attachment.deleted',
1624
1641
  aggregateId: id,
1625
1642
  aggregateType: 'Attachment',
@@ -1774,7 +1791,7 @@ export class CriticalPathEngine {
1774
1791
  const now = new Date().toISOString();
1775
1792
 
1776
1793
  const event: TeamCreatedEvent = {
1777
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1794
+ id: `evt_${crypto.randomUUID()}`,
1778
1795
  name: 'team.created',
1779
1796
  aggregateId: created.id,
1780
1797
  aggregateType: 'Team',
@@ -1787,15 +1804,35 @@ export class CriticalPathEngine {
1787
1804
  }
1788
1805
 
1789
1806
  async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1790
- if (!(await this.getTeam(id))) return null;
1807
+ const existing = await this.getTeam(id);
1808
+ if (!existing) return null;
1791
1809
  await this.requireWorkspaceAccess('workspace.manage');
1792
- return this.store.updateTeam(id, this.withoutTenant(updates));
1810
+ const updated = await this.store.updateTeam(id, this.withoutTenant(updates));
1811
+ if (updated) {
1812
+ await this.publishEvent<TeamUpdatedEvent>({
1813
+ name: 'team.updated',
1814
+ aggregateId: id,
1815
+ aggregateType: 'Team',
1816
+ payload: { team: updated, previous: existing }
1817
+ });
1818
+ }
1819
+ return updated;
1793
1820
  }
1794
1821
 
1795
1822
  async deleteTeam(id: string): Promise<boolean> {
1796
- if (!(await this.getTeam(id))) return false;
1823
+ const existing = await this.getTeam(id);
1824
+ if (!existing) return false;
1797
1825
  await this.requireWorkspaceAccess('workspace.manage');
1798
- return this.store.deleteTeam(id);
1826
+ const deleted = await this.store.deleteTeam(id);
1827
+ if (deleted) {
1828
+ await this.publishEvent<TeamDeletedEvent>({
1829
+ name: 'team.deleted',
1830
+ aggregateId: id,
1831
+ aggregateType: 'Team',
1832
+ payload: { teamId: id, name: existing.name, tenantId: existing.tenantId }
1833
+ });
1834
+ }
1835
+ return deleted;
1799
1836
  }
1800
1837
 
1801
1838
  // --- Containers ---
@@ -1816,7 +1853,7 @@ export class CriticalPathEngine {
1816
1853
  const now = new Date().toISOString();
1817
1854
 
1818
1855
  const event: ContainerCreatedEvent = {
1819
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1856
+ id: `evt_${crypto.randomUUID()}`,
1820
1857
  name: 'container.created',
1821
1858
  aggregateId: created.id,
1822
1859
  aggregateType: 'Container',
@@ -1832,7 +1869,16 @@ export class CriticalPathEngine {
1832
1869
  const existing = await this.getContainer(id);
1833
1870
  if (!existing) return null;
1834
1871
  await this.requireProjectAccess('plan.manage', existing.projectId);
1835
- return this.store.updateContainer(id, updates);
1872
+ const updated = await this.store.updateContainer(id, updates);
1873
+ if (updated) {
1874
+ await this.publishEvent<ContainerUpdatedEvent>({
1875
+ name: 'container.updated',
1876
+ aggregateId: id,
1877
+ aggregateType: 'Container',
1878
+ payload: { container: updated, previous: existing }
1879
+ });
1880
+ }
1881
+ return updated;
1836
1882
  }
1837
1883
 
1838
1884
  /** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
@@ -1844,7 +1890,16 @@ export class CriticalPathEngine {
1844
1890
  for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
1845
1891
  await this.store.updateContainer(child.id, { parentId: undefined });
1846
1892
  }
1847
- return this.store.deleteContainer(id);
1893
+ const deleted = await this.store.deleteContainer(id);
1894
+ if (deleted) {
1895
+ await this.publishEvent<ContainerDeletedEvent>({
1896
+ name: 'container.deleted',
1897
+ aggregateId: id,
1898
+ aggregateType: 'Container',
1899
+ payload: { containerId: id, projectId: existing.projectId, name: existing.name }
1900
+ });
1901
+ }
1902
+ return deleted;
1848
1903
  }
1849
1904
 
1850
1905
  /** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
@@ -1886,7 +1941,7 @@ export class CriticalPathEngine {
1886
1941
  const now = new Date().toISOString();
1887
1942
 
1888
1943
  const event: DeliverableCreatedEvent = {
1889
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1944
+ id: `evt_${crypto.randomUUID()}`,
1890
1945
  name: 'deliverable.created',
1891
1946
  aggregateId: created.id,
1892
1947
  aggregateType: 'Deliverable',
@@ -1919,7 +1974,7 @@ export class CriticalPathEngine {
1919
1974
 
1920
1975
  if (updates.status && updates.status !== existing.status) {
1921
1976
  const statusEvent: DeliverableStatusChangedEvent = {
1922
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1977
+ id: `evt_${crypto.randomUUID()}`,
1923
1978
  name: 'deliverable.status_changed',
1924
1979
  aggregateId: updated.id,
1925
1980
  aggregateType: 'Deliverable',
@@ -1934,7 +1989,7 @@ export class CriticalPathEngine {
1934
1989
  }
1935
1990
 
1936
1991
  const updateEvent: DeliverableUpdatedEvent = {
1937
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
1992
+ id: `evt_${crypto.randomUUID()}`,
1938
1993
  name: 'deliverable.updated',
1939
1994
  aggregateId: updated.id,
1940
1995
  aggregateType: 'Deliverable',
@@ -1963,7 +2018,7 @@ export class CriticalPathEngine {
1963
2018
  if (deleted) {
1964
2019
  const now = new Date().toISOString();
1965
2020
  const event: DeliverableDeletedEvent = {
1966
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2021
+ id: `evt_${crypto.randomUUID()}`,
1967
2022
  name: 'deliverable.deleted',
1968
2023
  aggregateId: id,
1969
2024
  aggregateType: 'Deliverable',
@@ -2044,10 +2099,16 @@ export class CriticalPathEngine {
2044
2099
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
2045
2100
  await this.requireProjectAccess('plan.manage', iteration.projectId);
2046
2101
  const created = await this.store.createIteration(iteration);
2102
+ await this.publishEvent<IterationCreatedEvent>({
2103
+ name: 'iteration.created',
2104
+ aggregateId: created.id,
2105
+ aggregateType: 'Iteration',
2106
+ payload: { iteration: created }
2107
+ });
2047
2108
  if (created.status === 'active') {
2048
2109
  const now = new Date().toISOString();
2049
2110
  const event: IterationStartedEvent = {
2050
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2111
+ id: `evt_${crypto.randomUUID()}`,
2051
2112
  name: 'iteration.started',
2052
2113
  aggregateId: created.id,
2053
2114
  aggregateType: 'Iteration',
@@ -2066,10 +2127,16 @@ export class CriticalPathEngine {
2066
2127
 
2067
2128
  const updated = await this.store.updateIteration(id, updates);
2068
2129
  if (updated) {
2130
+ await this.publishEvent<IterationUpdatedEvent>({
2131
+ name: 'iteration.updated',
2132
+ aggregateId: id,
2133
+ aggregateType: 'Iteration',
2134
+ payload: { iteration: updated, previous: existing }
2135
+ });
2069
2136
  const now = new Date().toISOString();
2070
2137
  if (existing.status !== 'active' && updated.status === 'active') {
2071
2138
  const event: IterationStartedEvent = {
2072
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2139
+ id: `evt_${crypto.randomUUID()}`,
2073
2140
  name: 'iteration.started',
2074
2141
  aggregateId: updated.id,
2075
2142
  aggregateType: 'Iteration',
@@ -2079,7 +2146,7 @@ export class CriticalPathEngine {
2079
2146
  await this.events.publish(event);
2080
2147
  } else if (existing.status !== 'completed' && updated.status === 'completed') {
2081
2148
  const event: IterationCompletedEvent = {
2082
- id: `evt_${Math.random().toString(36).substring(2, 9)}`,
2149
+ id: `evt_${crypto.randomUUID()}`,
2083
2150
  name: 'iteration.completed',
2084
2151
  aggregateId: updated.id,
2085
2152
  aggregateType: 'Iteration',
@@ -2098,7 +2165,16 @@ export class CriticalPathEngine {
2098
2165
  if (!existing) return false;
2099
2166
  await this.requireProjectAccess('plan.manage', existing.projectId);
2100
2167
  await this.clearTaskReferences(existing.projectId, 'iterationId', id);
2101
- return this.store.deleteIteration(id);
2168
+ const deleted = await this.store.deleteIteration(id);
2169
+ if (deleted) {
2170
+ await this.publishEvent<IterationDeletedEvent>({
2171
+ name: 'iteration.deleted',
2172
+ aggregateId: id,
2173
+ aggregateType: 'Iteration',
2174
+ payload: { iterationId: id, projectId: existing.projectId, name: existing.name }
2175
+ });
2176
+ }
2177
+ return deleted;
2102
2178
  }
2103
2179
 
2104
2180
  // --- Ladder of Abstraction & Critical Path Method ---
@@ -172,7 +172,7 @@ export class InMemoryStore implements StorageAdapter {
172
172
  }
173
173
 
174
174
  async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
175
- const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
175
+ const id = `proj_${crypto.randomUUID()}`;
176
176
  const now = new Date().toISOString();
177
177
  const key = project.key || generateProjectKey(project.name);
178
178
  const newProject: Project = {
@@ -212,7 +212,7 @@ export class InMemoryStore implements StorageAdapter {
212
212
  }
213
213
 
214
214
  async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
215
- const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
215
+ const id = `wf_${crypto.randomUUID()}`;
216
216
  const now = new Date().toISOString();
217
217
  const newWorkflow: Workflow = {
218
218
  ...workflow,
@@ -258,7 +258,7 @@ export class InMemoryStore implements StorageAdapter {
258
258
  }
259
259
 
260
260
  async createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task> {
261
- const id = `task_${Math.random().toString(36).substring(2, 9)}`;
261
+ const id = `task_${crypto.randomUUID()}`;
262
262
  const now = new Date().toISOString();
263
263
  const newTask: Task = {
264
264
  ...task,
@@ -296,7 +296,7 @@ export class InMemoryStore implements StorageAdapter {
296
296
  }
297
297
 
298
298
  async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
299
- const id = `team_${Math.random().toString(36).substring(2, 9)}`;
299
+ const id = `team_${crypto.randomUUID()}`;
300
300
  const now = new Date().toISOString();
301
301
  const newTeam: Team = { ...team, id, createdAt: now, updatedAt: now };
302
302
  this.teams.set(id, newTeam);
@@ -325,7 +325,7 @@ export class InMemoryStore implements StorageAdapter {
325
325
  }
326
326
 
327
327
  async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
328
- const id = `cnt_${Math.random().toString(36).substring(2, 9)}`;
328
+ const id = `cnt_${crypto.randomUUID()}`;
329
329
  const now = new Date().toISOString();
330
330
  const newContainer: TaskContainer = { ...container, id, createdAt: now, updatedAt: now };
331
331
  this.containers.set(id, newContainer);
@@ -354,7 +354,7 @@ export class InMemoryStore implements StorageAdapter {
354
354
  }
355
355
 
356
356
  async createDeliverable(deliverable: CreateDeliverableInput): Promise<Deliverable> {
357
- const id = (deliverable as any).id || `deliv_${Math.random().toString(36).substring(2, 9)}`;
357
+ const id = (deliverable as any).id || `deliv_${crypto.randomUUID()}`;
358
358
  const now = new Date().toISOString();
359
359
  const newDeliverable: Deliverable = {
360
360
  ...deliverable,
@@ -395,7 +395,7 @@ export class InMemoryStore implements StorageAdapter {
395
395
  }
396
396
 
397
397
  async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
398
- const id = `iter_${Math.random().toString(36).substring(2, 9)}`;
398
+ const id = `iter_${crypto.randomUUID()}`;
399
399
  const newIteration: Iteration = {
400
400
  ...iteration,
401
401
  id,
@@ -427,7 +427,7 @@ export class InMemoryStore implements StorageAdapter {
427
427
  }
428
428
 
429
429
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
430
- const id = `cmt_${Math.random().toString(36).substring(2, 9)}`;
430
+ const id = `cmt_${crypto.randomUUID()}`;
431
431
  const now = new Date().toISOString();
432
432
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
433
433
  this.comments.set(id, newComment);
@@ -501,7 +501,7 @@ export class InMemoryStore implements StorageAdapter {
501
501
  }
502
502
 
503
503
  async createAttachment(attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Attachment> {
504
- const id = `att_${Math.random().toString(36).substring(2, 9)}`;
504
+ const id = `att_${crypto.randomUUID()}`;
505
505
  const now = new Date().toISOString();
506
506
  const newAttachment: Attachment = {
507
507
  ...attachment,
@@ -532,7 +532,7 @@ export class InMemoryStore implements StorageAdapter {
532
532
  }
533
533
 
534
534
  async logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity> {
535
- const id = `act_${Math.random().toString(36).substring(2, 9)}`;
535
+ const id = `act_${crypto.randomUUID()}`;
536
536
  const newAct: Activity = { ...activity, id, createdAt: new Date().toISOString() };
537
537
  this.activities.unshift(newAct);
538
538
  return newAct;
@@ -544,7 +544,7 @@ export class InMemoryStore implements StorageAdapter {
544
544
  }
545
545
 
546
546
  async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string }): Promise<TimeEntry> {
547
- const id = `time_${Math.random().toString(36).substring(2, 9)}`;
547
+ const id = `time_${crypto.randomUUID()}`;
548
548
  const newEntry: TimeEntry = { ...entry, id, loggedAt: entry.loggedAt || new Date().toISOString() };
549
549
  this.timeEntries.set(id, newEntry);
550
550
  return newEntry;
@@ -566,7 +566,7 @@ export class InMemoryStore implements StorageAdapter {
566
566
  }
567
567
 
568
568
  async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
569
- const id = `dep_${Math.random().toString(36).substring(2, 9)}`;
569
+ const id = `dep_${crypto.randomUUID()}`;
570
570
  const newDep: TaskDependency = { ...dep, id };
571
571
  this.dependencies.set(id, newDep);
572
572
  return newDep;
@@ -586,7 +586,7 @@ export class InMemoryStore implements StorageAdapter {
586
586
  }
587
587
 
588
588
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
589
- const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
589
+ const id = `wh_${crypto.randomUUID()}`;
590
590
  const newWh: Webhook = { ...webhook, id, createdAt: new Date().toISOString() };
591
591
  this.webhooks.set(id, newWh);
592
592
  return newWh;