@critical-path/core 0.26.0 → 0.27.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 (68) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +13 -0
  3. package/dist/domain/events.d.ts +5 -0
  4. package/dist/domain/events.d.ts.map +1 -1
  5. package/dist/domain/events.js +34 -0
  6. package/dist/domain/events.js.map +1 -1
  7. package/dist/engine/index.d.ts +29 -2
  8. package/dist/engine/index.d.ts.map +1 -1
  9. package/dist/engine/index.js +102 -56
  10. package/dist/engine/index.js.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/schemas/index.d.ts +91 -48
  16. package/dist/schemas/index.d.ts.map +1 -1
  17. package/dist/schemas/index.js +13 -0
  18. package/dist/schemas/index.js.map +1 -1
  19. package/dist/schemas/schemas.test.js +1 -0
  20. package/dist/schemas/schemas.test.js.map +1 -1
  21. package/dist/store/firebase.d.ts +3 -0
  22. package/dist/store/firebase.d.ts.map +1 -1
  23. package/dist/store/firebase.js +21 -0
  24. package/dist/store/firebase.js.map +1 -1
  25. package/dist/store/firebase.test.js +17 -0
  26. package/dist/store/firebase.test.js.map +1 -1
  27. package/dist/store/index.d.ts +6 -0
  28. package/dist/store/index.d.ts.map +1 -1
  29. package/dist/store/index.js +14 -0
  30. package/dist/store/index.js.map +1 -1
  31. package/dist/store/sqlite.d.ts +4 -0
  32. package/dist/store/sqlite.d.ts.map +1 -1
  33. package/dist/store/sqlite.js +42 -8
  34. package/dist/store/sqlite.js.map +1 -1
  35. package/dist/types/index.d.ts +16 -1
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/webhooks/dispatcher.d.ts +80 -0
  38. package/dist/webhooks/dispatcher.d.ts.map +1 -0
  39. package/dist/webhooks/dispatcher.js +133 -0
  40. package/dist/webhooks/dispatcher.js.map +1 -0
  41. package/dist/webhooks/index.d.ts +3 -0
  42. package/dist/webhooks/index.d.ts.map +1 -0
  43. package/dist/webhooks/index.js +3 -0
  44. package/dist/webhooks/index.js.map +1 -0
  45. package/dist/webhooks/signature.d.ts +28 -0
  46. package/dist/webhooks/signature.d.ts.map +1 -0
  47. package/dist/webhooks/signature.js +42 -0
  48. package/dist/webhooks/signature.js.map +1 -0
  49. package/dist/webhooks/webhooks.test.d.ts +2 -0
  50. package/dist/webhooks/webhooks.test.d.ts.map +1 -0
  51. package/dist/webhooks/webhooks.test.js +153 -0
  52. package/dist/webhooks/webhooks.test.js.map +1 -0
  53. package/package.json +1 -1
  54. package/src/domain/events.ts +42 -3
  55. package/src/engine/index.ts +124 -59
  56. package/src/index.ts +1 -0
  57. package/src/schemas/index.ts +17 -0
  58. package/src/schemas/schemas.test.ts +6 -3
  59. package/src/store/firebase.test.ts +20 -0
  60. package/src/store/firebase.ts +22 -0
  61. package/src/store/index.ts +19 -0
  62. package/src/store/sqlite.ts +54 -7
  63. package/src/types/index.ts +15 -28
  64. package/src/webhooks/dispatcher.ts +190 -0
  65. package/src/webhooks/index.ts +2 -0
  66. package/src/webhooks/signature.ts +57 -0
  67. package/src/webhooks/webhooks.test.ts +200 -0
  68. package/tsconfig.tsbuildinfo +1 -1
@@ -17,7 +17,8 @@ import type {
17
17
  PresignedUrlOptions,
18
18
  PresignedUploadResult,
19
19
  TimeEntry,
20
- WebhookEvent,
20
+ Webhook,
21
+ PublicWebhook,
21
22
  Workflow,
22
23
  CreateTaskInput,
23
24
  CreateProjectInput,
@@ -85,6 +86,9 @@ import { validateAttachmentUrl, AttachmentValidationError } from '../domain/enti
85
86
  import { validateCustomFieldValues } from '../domain/custom-fields.js';
86
87
  import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
87
88
  import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
89
+ import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
90
+ import { generateWebhookSecret } from '../webhooks/signature.js';
91
+ import type { DomainEvent } from '../domain/events.js';
88
92
  import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
89
93
  import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
90
94
  import {
@@ -100,6 +104,8 @@ export class CriticalPathEngine {
100
104
  public readonly fileStorage?: FileStorageAdapter;
101
105
  public readonly plugins: PluginRegistry;
102
106
  public readonly events: DomainEventBus;
107
+ /** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
108
+ public readonly webhooks: WebhookDispatcher;
103
109
  public readonly ready: Promise<void> = Promise.resolve();
104
110
  /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
105
111
  public readonly actor?: Actor;
@@ -115,6 +121,14 @@ export class CriticalPathEngine {
115
121
  this.fileStorage = config.fileStorage;
116
122
  this.plugins = new PluginRegistry();
117
123
  this.events = new DomainEventBus();
124
+ this.webhooks = new WebhookDispatcher(
125
+ {
126
+ listWebhooks: () => this.listAllWebhooks(),
127
+ resolveTenant: (event) => this.resolveEventTenant(event)
128
+ },
129
+ config.webhookDelivery
130
+ );
131
+ this.events.subscribe('*', (event) => this.webhooks.handle(event));
118
132
 
119
133
  if (config.plugins) {
120
134
  for (const plugin of config.plugins) {
@@ -319,7 +333,6 @@ export class CriticalPathEngine {
319
333
  action: 'workflow.created',
320
334
  details: { name: created.name }
321
335
  });
322
- this.dispatchWebhook('workflow.created', { workflow: created });
323
336
  return created;
324
337
  }
325
338
 
@@ -347,7 +360,6 @@ export class CriticalPathEngine {
347
360
  action: 'workflow.updated',
348
361
  details: { name: updated.name }
349
362
  });
350
- this.dispatchWebhook('workflow.updated', { workflow: updated, previous: existing });
351
363
  }
352
364
  return updated;
353
365
  }
@@ -366,7 +378,7 @@ export class CriticalPathEngine {
366
378
  aggregateId: id,
367
379
  aggregateType: 'Workflow',
368
380
  occurredAt: now,
369
- payload: { workflowId: id, name: existing.name }
381
+ payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
370
382
  };
371
383
  await this.events.publish(event);
372
384
 
@@ -375,7 +387,6 @@ export class CriticalPathEngine {
375
387
  action: 'workflow.deleted',
376
388
  details: { name: existing.name }
377
389
  });
378
- this.dispatchWebhook('workflow.deleted', { workflowId: id, name: existing.name });
379
390
  }
380
391
  return deleted;
381
392
  }
@@ -456,7 +467,6 @@ export class CriticalPathEngine {
456
467
  action: 'project.created',
457
468
  details: { name: created.name, key: created.key }
458
469
  });
459
- this.dispatchWebhook('project.created', { project: created });
460
470
  return created;
461
471
  }
462
472
 
@@ -486,7 +496,6 @@ export class CriticalPathEngine {
486
496
  action: 'project.updated',
487
497
  details: { name: updated.name }
488
498
  });
489
- this.dispatchWebhook('project.updated', { project: updated });
490
499
  }
491
500
  return updated;
492
501
  }
@@ -519,7 +528,7 @@ export class CriticalPathEngine {
519
528
  aggregateId: id,
520
529
  aggregateType: 'Project',
521
530
  occurredAt: now,
522
- payload: { projectId: id, name: existing.name, deletedTaskIds }
531
+ payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
523
532
  };
524
533
  await this.events.publish(event);
525
534
 
@@ -529,7 +538,6 @@ export class CriticalPathEngine {
529
538
  action: 'project.deleted',
530
539
  details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
531
540
  });
532
- this.dispatchWebhook('project.deleted', { projectId: id, name: existing.name, deletedTaskIds });
533
541
  }
534
542
  return deleted;
535
543
  }
@@ -642,7 +650,6 @@ export class CriticalPathEngine {
642
650
  details: { title: created.title, status: created.status }
643
651
  });
644
652
 
645
- this.dispatchWebhook('task.created', { task: created });
646
653
  return created;
647
654
  }
648
655
 
@@ -925,10 +932,6 @@ export class CriticalPathEngine {
925
932
  action: 'task.blocked',
926
933
  details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
927
934
  });
928
- this.dispatchWebhook('task.blocked', {
929
- task: updated,
930
- reason: blockedReason
931
- });
932
935
  } else if (wasBlocked && !isNowBlocked) {
933
936
  const unblockedEvent: TaskUnblockedEvent = {
934
937
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -951,9 +954,6 @@ export class CriticalPathEngine {
951
954
  action: 'task.unblocked',
952
955
  details: { ...(actorName ? { actorName } : {}) }
953
956
  });
954
- this.dispatchWebhook('task.unblocked', {
955
- task: updated
956
- });
957
957
  }
958
958
 
959
959
  await this.store.logActivity({
@@ -968,10 +968,6 @@ export class CriticalPathEngine {
968
968
  }
969
969
  });
970
970
 
971
- this.dispatchWebhook(isStatusChange ? 'task.status_changed' : 'task.updated', {
972
- task: updated,
973
- previous: existing
974
- });
975
971
 
976
972
  return updated;
977
973
  }
@@ -1042,10 +1038,6 @@ export class CriticalPathEngine {
1042
1038
  details: { unblockedByTaskId: completedTask.id }
1043
1039
  });
1044
1040
 
1045
- this.dispatchWebhook('task.unblocked', {
1046
- task: updatedDownstream,
1047
- upstreamTaskId: completedTask.id
1048
- });
1049
1041
  }
1050
1042
  }
1051
1043
  } catch (err) {
@@ -1086,7 +1078,6 @@ export class CriticalPathEngine {
1086
1078
  action: 'task.deleted',
1087
1079
  details: { title: existing.title }
1088
1080
  });
1089
- this.dispatchWebhook('task.deleted', { taskId: id, title: existing.title });
1090
1081
  }
1091
1082
  return deleted;
1092
1083
  }
@@ -1297,7 +1288,6 @@ export class CriticalPathEngine {
1297
1288
  };
1298
1289
  await this.events.publish(event);
1299
1290
 
1300
- this.dispatchWebhook('comment.created', { comment: created });
1301
1291
  return created;
1302
1292
  }
1303
1293
 
@@ -1326,7 +1316,6 @@ export class CriticalPathEngine {
1326
1316
  };
1327
1317
  await this.events.publish(event);
1328
1318
 
1329
- this.dispatchWebhook('comment.updated', { comment: updated });
1330
1319
  return updated;
1331
1320
  }
1332
1321
 
@@ -1351,7 +1340,6 @@ export class CriticalPathEngine {
1351
1340
  };
1352
1341
  await this.events.publish(event);
1353
1342
 
1354
- this.dispatchWebhook('comment.deleted', { commentId: id, taskId: existing.taskId });
1355
1343
  }
1356
1344
  return deleted;
1357
1345
  }
@@ -1399,7 +1387,6 @@ export class CriticalPathEngine {
1399
1387
  };
1400
1388
  await this.events.publish(event);
1401
1389
 
1402
- this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
1403
1390
  return updated;
1404
1391
  }
1405
1392
 
@@ -1432,7 +1419,6 @@ export class CriticalPathEngine {
1432
1419
  };
1433
1420
  await this.events.publish(event);
1434
1421
 
1435
- this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
1436
1422
  return updated;
1437
1423
  }
1438
1424
 
@@ -1481,7 +1467,6 @@ export class CriticalPathEngine {
1481
1467
  };
1482
1468
  await this.events.publish(event);
1483
1469
 
1484
- this.dispatchWebhook('attachment.created', { attachment: created });
1485
1470
  return created;
1486
1471
  }
1487
1472
 
@@ -1510,11 +1495,15 @@ export class CriticalPathEngine {
1510
1495
  aggregateId: id,
1511
1496
  aggregateType: 'Attachment',
1512
1497
  occurredAt: now,
1513
- payload: { attachmentId: id, storageKey: existing.storageKey, url: existing.url }
1498
+ payload: {
1499
+ attachmentId: id,
1500
+ storageKey: existing.storageKey,
1501
+ url: existing.url,
1502
+ projectId: await this.projectIdOfAttachment(existing)
1503
+ }
1514
1504
  };
1515
1505
  await this.events.publish(event);
1516
1506
 
1517
- this.dispatchWebhook('attachment.deleted', { attachmentId: id, taskId: existing.taskId, projectId: existing.projectId });
1518
1507
  }
1519
1508
  return deleted;
1520
1509
  }
@@ -1639,7 +1628,6 @@ export class CriticalPathEngine {
1639
1628
  };
1640
1629
  await this.events.publish(event);
1641
1630
 
1642
- this.dispatchWebhook('team.created', { team: created });
1643
1631
  return created;
1644
1632
  }
1645
1633
 
@@ -1682,7 +1670,6 @@ export class CriticalPathEngine {
1682
1670
  };
1683
1671
  await this.events.publish(event);
1684
1672
 
1685
- this.dispatchWebhook('container.created', { container: created });
1686
1673
  return created;
1687
1674
  }
1688
1675
 
@@ -1743,7 +1730,6 @@ export class CriticalPathEngine {
1743
1730
  action: 'deliverable.created',
1744
1731
  details: { title: created.title, format: created.format }
1745
1732
  });
1746
- this.dispatchWebhook('deliverable.created', { deliverable: created });
1747
1733
  return created;
1748
1734
  }
1749
1735
 
@@ -1774,11 +1760,6 @@ export class CriticalPathEngine {
1774
1760
  }
1775
1761
  };
1776
1762
  await this.events.publish(statusEvent);
1777
- this.dispatchWebhook('deliverable.status_changed', {
1778
- deliverable: updated,
1779
- previousStatus: existing.status,
1780
- newStatus: updated.status
1781
- });
1782
1763
  }
1783
1764
 
1784
1765
  const updateEvent: DeliverableUpdatedEvent = {
@@ -1797,7 +1778,6 @@ export class CriticalPathEngine {
1797
1778
  action: 'deliverable.updated',
1798
1779
  details: { title: updated.title, status: updated.status }
1799
1780
  });
1800
- this.dispatchWebhook('deliverable.updated', { deliverable: updated });
1801
1781
  }
1802
1782
  return updated;
1803
1783
  }
@@ -1826,7 +1806,6 @@ export class CriticalPathEngine {
1826
1806
  action: 'deliverable.deleted',
1827
1807
  details: { title: existing.title }
1828
1808
  });
1829
- this.dispatchWebhook('deliverable.deleted', { deliverableId: id, projectId: existing.projectId });
1830
1809
  }
1831
1810
  return deleted;
1832
1811
  }
@@ -1904,7 +1883,6 @@ export class CriticalPathEngine {
1904
1883
  payload: { iteration: created }
1905
1884
  };
1906
1885
  await this.events.publish(event);
1907
- this.dispatchWebhook('iteration.started', { iteration: created });
1908
1886
  }
1909
1887
  return created;
1910
1888
  }
@@ -1927,7 +1905,6 @@ export class CriticalPathEngine {
1927
1905
  payload: { iteration: updated }
1928
1906
  };
1929
1907
  await this.events.publish(event);
1930
- this.dispatchWebhook('iteration.started', { iteration: updated });
1931
1908
  } else if (existing.status !== 'completed' && updated.status === 'completed') {
1932
1909
  const event: IterationCompletedEvent = {
1933
1910
  id: `evt_${Math.random().toString(36).substring(2, 9)}`,
@@ -1938,7 +1915,6 @@ export class CriticalPathEngine {
1938
1915
  payload: { iteration: updated }
1939
1916
  };
1940
1917
  await this.events.publish(event);
1941
- this.dispatchWebhook('iteration.completed', { iteration: updated });
1942
1918
  }
1943
1919
  }
1944
1920
  return updated;
@@ -2123,19 +2099,108 @@ export class CriticalPathEngine {
2123
2099
  );
2124
2100
  }
2125
2101
 
2126
- private async dispatchWebhook(event: WebhookEvent, payload: Record<string, unknown>): Promise<void> {
2102
+ // --- Webhooks ---
2103
+
2104
+ /** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
2105
+ private async listAllWebhooks(): Promise<Webhook[]> {
2106
+ const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
2107
+ ...w,
2108
+ id: `config_${i}`,
2109
+ createdAt: new Date(0).toISOString()
2110
+ }));
2111
+ return [...fromConfig, ...(await this.store.getWebhooks())];
2112
+ }
2113
+
2114
+ private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
2115
+ return { ...webhook, hasSecret: !!secret };
2116
+ }
2117
+
2118
+ /** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
2119
+ async getWebhooks(): Promise<PublicWebhook[]> {
2120
+ await this.requireWorkspaceAccess('workspace.manage');
2127
2121
  const webhooks = await this.store.getWebhooks();
2128
- const active = webhooks.filter((w) => w.active && w.events.includes(event));
2129
-
2130
- for (const wh of active) {
2131
- fetch(wh.url, {
2132
- method: 'POST',
2133
- headers: { 'Content-Type': 'application/json' },
2134
- body: JSON.stringify({ event, timestamp: new Date().toISOString(), data: payload })
2135
- }).catch(() => {
2136
- // Silent catch for webhook errors in dev
2137
- });
2122
+ return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
2123
+ }
2124
+
2125
+ async getWebhook(id: string): Promise<PublicWebhook | null> {
2126
+ await this.requireWorkspaceAccess('workspace.manage');
2127
+ const webhook = await this.store.getWebhook(id);
2128
+ return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
2129
+ }
2130
+
2131
+ /**
2132
+ * Registers a webhook. The signing secret is returned only here; generate one by omitting
2133
+ * `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
2134
+ */
2135
+ async createWebhook(input: {
2136
+ name: string;
2137
+ url: string;
2138
+ events: Webhook['events'];
2139
+ secret?: string;
2140
+ active?: boolean;
2141
+ }): Promise<{ webhook: PublicWebhook; secret: string }> {
2142
+ await this.requireWorkspaceAccess('workspace.manage');
2143
+ this.validateWebhookUrl(input.url);
2144
+ const secret = input.secret ?? generateWebhookSecret();
2145
+ const created = await this.store.addWebhook({
2146
+ name: input.name,
2147
+ url: input.url,
2148
+ events: input.events,
2149
+ secret,
2150
+ active: input.active ?? true,
2151
+ tenantId: this.actor?.tenantId
2152
+ });
2153
+ this.webhooks.invalidate();
2154
+ return { webhook: this.toPublicWebhook(created), secret };
2155
+ }
2156
+
2157
+ async updateWebhook(
2158
+ id: string,
2159
+ updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
2160
+ ): Promise<PublicWebhook | null> {
2161
+ if (!(await this.getWebhook(id))) return null;
2162
+ if (updates.url) this.validateWebhookUrl(updates.url);
2163
+ const { name, url, events, active, secret } = updates;
2164
+ const updated = await this.store.updateWebhook(
2165
+ id,
2166
+ Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
2167
+ );
2168
+ this.webhooks.invalidate();
2169
+ return updated ? this.toPublicWebhook(updated) : null;
2170
+ }
2171
+
2172
+ async deleteWebhook(id: string): Promise<boolean> {
2173
+ if (!(await this.getWebhook(id))) return false;
2174
+ const deleted = await this.store.deleteWebhook(id);
2175
+ this.webhooks.invalidate();
2176
+ return deleted;
2177
+ }
2178
+
2179
+ private validateWebhookUrl(url: string): void {
2180
+ try {
2181
+ assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
2182
+ } catch (err) {
2183
+ throw new ValidationError((err as Error).message);
2138
2184
  }
2139
2185
  }
2140
- }
2141
2186
 
2187
+ /** Finds the tenant an event belongs to, from the entity in its payload or its project. */
2188
+ private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
2189
+ const p = event.payload as Record<string, any>;
2190
+ const owner = p.project ?? p.workflow ?? p.team;
2191
+ if (owner) return owner.tenantId ?? undefined;
2192
+ // Deleted projects and workflows carry their tenant in the payload.
2193
+ if ('tenantId' in p) return p.tenantId ?? undefined;
2194
+
2195
+ const projectId: string | undefined =
2196
+ p.projectId ??
2197
+ p.task?.projectId ??
2198
+ p.deliverable?.projectId ??
2199
+ p.iteration?.projectId ??
2200
+ p.container?.projectId ??
2201
+ p.attachment?.projectId ??
2202
+ (await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
2203
+ if (!projectId) return undefined;
2204
+ return (await this.store.getProject(projectId))?.tenantId ?? undefined;
2205
+ }
2206
+ }
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from './storage/index.js';
5
5
  export * from './plugins/index.js';
6
6
  export * from './engine/index.js';
7
7
  export * from './domain/index.js';
8
+ export * from './webhooks/index.js';
8
9
 
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { z } from 'zod';
12
12
  import { ValidationError, type ValidationIssue } from '../domain/errors.js';
13
+ import { DOMAIN_EVENT_NAMES } from '../domain/events.js';
13
14
 
14
15
  /**
15
16
  * Parses `data` with `schema`, returning the cleaned value (unknown keys stripped, defaults
@@ -322,6 +323,20 @@ export const LogTimeSchema = strictObject({
322
323
  loggedAt: isoString.optional()
323
324
  });
324
325
 
326
+ // --- Webhooks ---
327
+
328
+ export const CreateWebhookSchema = strictObject({
329
+ name: nonEmpty,
330
+ /** http(s) URL; private and local addresses are rejected unless the engine allows them. */
331
+ url: nonEmpty,
332
+ /** Domain event names (e.g. `task.created`) or `'*'` for all events. */
333
+ events: z.array(z.enum(['*', ...DOMAIN_EVENT_NAMES])).min(1),
334
+ /** Signing secret (at least 16 characters). Generated when omitted and returned once. */
335
+ secret: z.string().min(16).optional(),
336
+ active: z.boolean().optional()
337
+ });
338
+ export const UpdateWebhookSchema = CreateWebhookSchema.partial();
339
+
325
340
  export type CreateTaskPayload = z.infer<typeof CreateTaskSchema>;
326
341
  export type UpdateTaskPayload = z.infer<typeof UpdateTaskSchema>;
327
342
  export type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
@@ -350,4 +365,6 @@ export type CreateAttachmentBody = z.input<typeof CreateAttachmentSchema>;
350
365
  export type UploadAttachmentBody = z.input<typeof UploadAttachmentSchema>;
351
366
  export type PresignAttachmentBody = z.input<typeof PresignAttachmentSchema>;
352
367
  export type LogTimeBody = z.input<typeof LogTimeSchema>;
368
+ export type CreateWebhookBody = z.input<typeof CreateWebhookSchema>;
369
+ export type UpdateWebhookBody = z.input<typeof UpdateWebhookSchema>;
353
370
 
@@ -13,7 +13,8 @@ import type {
13
13
  Iteration,
14
14
  Comment,
15
15
  CreateAttachmentInput,
16
- TimeEntry
16
+ TimeEntry,
17
+ Webhook
17
18
  } from '../types/index.js';
18
19
  import {
19
20
  CreateTaskSchema,
@@ -30,7 +31,8 @@ import {
30
31
  CreateCommentSchema,
31
32
  UpdateCommentSchema,
32
33
  CreateAttachmentSchema,
33
- LogTimeSchema
34
+ LogTimeSchema,
35
+ CreateWebhookSchema
34
36
  } from './index.js';
35
37
 
36
38
  /**
@@ -69,7 +71,8 @@ const keyChecks: true[] = [
69
71
  true as SameKeys<Infer<typeof CreateCommentSchema>, Omit<Comment, ServerAssigned | 'reactions' | CommentIdentity>>,
70
72
  true as SameKeys<Infer<typeof UpdateCommentSchema>, Pick<Comment, 'content' | 'mentions' | 'metadata'>>,
71
73
  true as SameKeys<Infer<typeof CreateAttachmentSchema>, Omit<CreateAttachmentInput, UploaderIdentity>>,
72
- true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>
74
+ true as SameKeys<Infer<typeof LogTimeSchema>, Omit<TimeEntry, 'id' | 'userId'>>,
75
+ true as SameKeys<Infer<typeof CreateWebhookSchema>, Pick<Webhook, 'name' | 'url' | 'events' | 'secret' | 'active'>>
73
76
  ];
74
77
 
75
78
  describe('request schemas', () => {
@@ -107,5 +107,25 @@ describe('FirebaseStore', () => {
107
107
  expect(byUpstream).toHaveLength(1);
108
108
  expect(byUpstream[0].id).toBe(dep.id);
109
109
  });
110
+
111
+ it('should create, update, clear fields on, and delete webhooks', async () => {
112
+ const created = await store.addWebhook({
113
+ name: 'Hook',
114
+ url: 'https://hooks.example.com/x',
115
+ events: ['task.created'],
116
+ secret: 'whsec_test',
117
+ active: true,
118
+ tenantId: 'acme'
119
+ });
120
+ expect(await store.getWebhook(created.id)).toMatchObject({ name: 'Hook', tenantId: 'acme' });
121
+
122
+ const updated = await store.updateWebhook(created.id, { events: ['*'], secret: undefined });
123
+ expect(updated?.events).toEqual(['*']);
124
+ expect((await store.getWebhook(created.id))?.secret).toBeUndefined();
125
+
126
+ expect(await store.deleteWebhook(created.id)).toBe(true);
127
+ expect(await store.getWebhook(created.id)).toBeNull();
128
+ expect(await store.deleteWebhook(created.id)).toBe(false);
129
+ });
110
130
  });
111
131
 
@@ -573,6 +573,11 @@ export class FirebaseStore implements StorageAdapter {
573
573
  return snap.docs.map((doc) => ({ ...doc.data(), id: doc.id }));
574
574
  }
575
575
 
576
+ async getWebhook(id: string): Promise<Webhook | null> {
577
+ const doc = await this.db.collection('webhooks').doc(id).get();
578
+ return doc.exists ? ({ ...doc.data(), id: doc.id } as Webhook) : null;
579
+ }
580
+
576
581
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
577
582
  const docRef = this.db.collection('webhooks').doc();
578
583
  const now = new Date().toISOString();
@@ -580,4 +585,21 @@ export class FirebaseStore implements StorageAdapter {
580
585
  await docRef.set(sanitizeFirestoreData(newWh));
581
586
  return newWh;
582
587
  }
588
+
589
+ async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
590
+ const existing = await this.getWebhook(id);
591
+ if (!existing) return null;
592
+ const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
593
+ // Write the whole document (no merge) so fields cleared with `undefined` are removed.
594
+ await this.db.collection('webhooks').doc(id).set(sanitizeFirestoreData(updated));
595
+ return updated;
596
+ }
597
+
598
+ async deleteWebhook(id: string): Promise<boolean> {
599
+ const ref = this.db.collection('webhooks').doc(id);
600
+ const doc = await ref.get();
601
+ if (!doc.exists) return false;
602
+ await ref.delete();
603
+ return true;
604
+ }
583
605
  }
@@ -101,7 +101,10 @@ export interface DependencyRepository {
101
101
 
102
102
  export interface WebhookRepository {
103
103
  getWebhooks(): Promise<Webhook[]>;
104
+ getWebhook(id: string): Promise<Webhook | null>;
104
105
  addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
106
+ updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null>;
107
+ deleteWebhook(id: string): Promise<boolean>;
105
108
  }
106
109
 
107
110
  export interface DeliverableRepository {
@@ -538,10 +541,26 @@ export class InMemoryStore implements StorageAdapter {
538
541
  return Array.from(this.webhooks.values());
539
542
  }
540
543
 
544
+ async getWebhook(id: string): Promise<Webhook | null> {
545
+ return this.webhooks.get(id) ?? null;
546
+ }
547
+
541
548
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
542
549
  const id = `wh_${Math.random().toString(36).substring(2, 9)}`;
543
550
  const newWh: Webhook = { ...webhook, id, createdAt: new Date().toISOString() };
544
551
  this.webhooks.set(id, newWh);
545
552
  return newWh;
546
553
  }
554
+
555
+ async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
556
+ const existing = this.webhooks.get(id);
557
+ if (!existing) return null;
558
+ const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
559
+ this.webhooks.set(id, updated);
560
+ return updated;
561
+ }
562
+
563
+ async deleteWebhook(id: string): Promise<boolean> {
564
+ return this.webhooks.delete(id);
565
+ }
547
566
  }
@@ -280,6 +280,14 @@ export class SQLiteStore implements StorageAdapter {
280
280
  // Column may already exist
281
281
  }
282
282
 
283
+ for (const column of ['name TEXT', 'tenantId TEXT']) {
284
+ try {
285
+ this.db.exec(`ALTER TABLE webhooks ADD COLUMN ${column}`);
286
+ } catch {
287
+ // Column may already exist
288
+ }
289
+ }
290
+
283
291
  for (const table of ['projects', 'workflows', 'teams']) {
284
292
  try {
285
293
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN tenantId TEXT`);
@@ -1175,11 +1183,48 @@ export class SQLiteStore implements StorageAdapter {
1175
1183
  async getWebhooks(): Promise<Webhook[]> {
1176
1184
  const stmt = this.db.prepare('SELECT * FROM webhooks');
1177
1185
  const rows = stmt.all() as any[];
1178
- return rows.map((r) => ({
1179
- ...r,
1180
- events: JSON.parse(r.events),
1181
- active: Boolean(r.active)
1182
- }));
1186
+ return rows.map((r) => this.mapWebhook(r));
1187
+ }
1188
+
1189
+ async getWebhook(id: string): Promise<Webhook | null> {
1190
+ const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
1191
+ return row ? this.mapWebhook(row) : null;
1192
+ }
1193
+
1194
+ async updateWebhook(id: string, updates: Partial<Omit<Webhook, 'id' | 'createdAt'>>): Promise<Webhook | null> {
1195
+ const existing = await this.getWebhook(id);
1196
+ if (!existing) return null;
1197
+ const updated: Webhook = { ...existing, ...updates, id, createdAt: existing.createdAt };
1198
+ this.db
1199
+ .prepare('UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, active = ?, tenantId = ? WHERE id = ?')
1200
+ .run(
1201
+ updated.name ?? null,
1202
+ updated.url,
1203
+ JSON.stringify(updated.events),
1204
+ updated.secret ?? null,
1205
+ updated.active ? 1 : 0,
1206
+ updated.tenantId ?? null,
1207
+ id
1208
+ );
1209
+ return updated;
1210
+ }
1211
+
1212
+ async deleteWebhook(id: string): Promise<boolean> {
1213
+ const result = this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
1214
+ return Number(result.changes) > 0;
1215
+ }
1216
+
1217
+ private mapWebhook(row: any): Webhook {
1218
+ return {
1219
+ id: row.id,
1220
+ name: row.name ?? '',
1221
+ url: row.url,
1222
+ events: JSON.parse(row.events),
1223
+ secret: row.secret ?? undefined,
1224
+ active: Boolean(row.active),
1225
+ tenantId: row.tenantId ?? undefined,
1226
+ createdAt: row.createdAt
1227
+ };
1183
1228
  }
1184
1229
 
1185
1230
  async addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook> {
@@ -1188,15 +1233,17 @@ export class SQLiteStore implements StorageAdapter {
1188
1233
  const newWh: Webhook = { ...webhook, id, createdAt: now };
1189
1234
 
1190
1235
  const stmt = this.db.prepare(`
1191
- INSERT INTO webhooks (id, url, events, secret, active, createdAt)
1192
- VALUES (?, ?, ?, ?, ?, ?)
1236
+ INSERT INTO webhooks (id, name, url, events, secret, active, tenantId, createdAt)
1237
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1193
1238
  `);
1194
1239
  stmt.run(
1195
1240
  newWh.id,
1241
+ newWh.name ?? null,
1196
1242
  newWh.url,
1197
1243
  JSON.stringify(newWh.events),
1198
1244
  newWh.secret || null,
1199
1245
  newWh.active ? 1 : 0,
1246
+ newWh.tenantId ?? null,
1200
1247
  newWh.createdAt
1201
1248
  );
1202
1249
  return newWh;