@critical-path/core 0.36.0 → 0.37.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 (214) hide show
  1. package/README.md +1 -1
  2. package/dist/domain/authorization.d.ts.map +1 -1
  3. package/dist/domain/authorization.js +10 -7
  4. package/dist/domain/authorization.js.map +1 -1
  5. package/dist/engine/index.d.ts +6 -0
  6. package/dist/engine/index.d.ts.map +1 -1
  7. package/dist/engine/index.js +28 -2
  8. package/dist/engine/index.js.map +1 -1
  9. package/dist/schemas/index.d.ts +42 -12
  10. package/dist/schemas/index.d.ts.map +1 -1
  11. package/dist/schemas/index.js +4 -1
  12. package/dist/schemas/index.js.map +1 -1
  13. package/dist/store/firebase.d.ts +2 -2
  14. package/dist/store/firebase.d.ts.map +1 -1
  15. package/dist/store/firebase.js +4 -2
  16. package/dist/store/firebase.js.map +1 -1
  17. package/dist/store/index.d.ts +7 -2
  18. package/dist/store/index.d.ts.map +1 -1
  19. package/dist/store/index.js +3 -2
  20. package/dist/store/index.js.map +1 -1
  21. package/dist/store/sqlite.d.ts +2 -2
  22. package/dist/store/sqlite.d.ts.map +1 -1
  23. package/dist/store/sqlite.js +5 -3
  24. package/dist/store/sqlite.js.map +1 -1
  25. package/dist/testing/conformance.d.ts.map +1 -1
  26. package/dist/testing/conformance.js +9 -0
  27. package/dist/testing/conformance.js.map +1 -1
  28. package/dist/types/index.d.ts +13 -2
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/package.json +5 -1
  31. package/CHANGELOG.md +0 -593
  32. package/dist/deliverables.test.d.ts +0 -2
  33. package/dist/deliverables.test.d.ts.map +0 -1
  34. package/dist/deliverables.test.js +0 -232
  35. package/dist/deliverables.test.js.map +0 -1
  36. package/dist/domain/calendar.test.d.ts +0 -2
  37. package/dist/domain/calendar.test.d.ts.map +0 -1
  38. package/dist/domain/calendar.test.js +0 -181
  39. package/dist/domain/calendar.test.js.map +0 -1
  40. package/dist/domain/cpm.test.d.ts +0 -2
  41. package/dist/domain/cpm.test.d.ts.map +0 -1
  42. package/dist/domain/cpm.test.js +0 -166
  43. package/dist/domain/cpm.test.js.map +0 -1
  44. package/dist/domain/ddd.test.d.ts +0 -2
  45. package/dist/domain/ddd.test.d.ts.map +0 -1
  46. package/dist/domain/ddd.test.js +0 -349
  47. package/dist/domain/ddd.test.js.map +0 -1
  48. package/dist/domain/ladder.test.d.ts +0 -2
  49. package/dist/domain/ladder.test.d.ts.map +0 -1
  50. package/dist/domain/ladder.test.js +0 -213
  51. package/dist/domain/ladder.test.js.map +0 -1
  52. package/dist/domain/metrics.test.d.ts +0 -2
  53. package/dist/domain/metrics.test.d.ts.map +0 -1
  54. package/dist/domain/metrics.test.js +0 -252
  55. package/dist/domain/metrics.test.js.map +0 -1
  56. package/dist/domain/task-duration.test.d.ts +0 -2
  57. package/dist/domain/task-duration.test.d.ts.map +0 -1
  58. package/dist/domain/task-duration.test.js +0 -307
  59. package/dist/domain/task-duration.test.js.map +0 -1
  60. package/dist/domain/workload.test.d.ts +0 -2
  61. package/dist/domain/workload.test.d.ts.map +0 -1
  62. package/dist/domain/workload.test.js +0 -261
  63. package/dist/domain/workload.test.js.map +0 -1
  64. package/dist/engine/actor.test.d.ts +0 -2
  65. package/dist/engine/actor.test.d.ts.map +0 -1
  66. package/dist/engine/actor.test.js +0 -54
  67. package/dist/engine/actor.test.js.map +0 -1
  68. package/dist/engine/authorization.test.d.ts +0 -2
  69. package/dist/engine/authorization.test.d.ts.map +0 -1
  70. package/dist/engine/authorization.test.js +0 -154
  71. package/dist/engine/authorization.test.js.map +0 -1
  72. package/dist/engine/cascade.test.d.ts +0 -2
  73. package/dist/engine/cascade.test.d.ts.map +0 -1
  74. package/dist/engine/cascade.test.js +0 -99
  75. package/dist/engine/cascade.test.js.map +0 -1
  76. package/dist/engine/config.test.d.ts +0 -2
  77. package/dist/engine/config.test.d.ts.map +0 -1
  78. package/dist/engine/config.test.js +0 -46
  79. package/dist/engine/config.test.js.map +0 -1
  80. package/dist/engine/entity-events.test.d.ts +0 -2
  81. package/dist/engine/entity-events.test.d.ts.map +0 -1
  82. package/dist/engine/entity-events.test.js +0 -61
  83. package/dist/engine/entity-events.test.js.map +0 -1
  84. package/dist/index.test.d.ts +0 -2
  85. package/dist/index.test.d.ts.map +0 -1
  86. package/dist/index.test.js +0 -429
  87. package/dist/index.test.js.map +0 -1
  88. package/dist/plugins/plugins.test.d.ts +0 -2
  89. package/dist/plugins/plugins.test.d.ts.map +0 -1
  90. package/dist/plugins/plugins.test.js +0 -137
  91. package/dist/plugins/plugins.test.js.map +0 -1
  92. package/dist/schemas/schemas.test.d.ts +0 -2
  93. package/dist/schemas/schemas.test.d.ts.map +0 -1
  94. package/dist/schemas/schemas.test.js +0 -58
  95. package/dist/schemas/schemas.test.js.map +0 -1
  96. package/dist/status.test.d.ts +0 -2
  97. package/dist/status.test.d.ts.map +0 -1
  98. package/dist/status.test.js +0 -295
  99. package/dist/status.test.js.map +0 -1
  100. package/dist/storage/storage.test.d.ts +0 -2
  101. package/dist/storage/storage.test.d.ts.map +0 -1
  102. package/dist/storage/storage.test.js +0 -236
  103. package/dist/storage/storage.test.js.map +0 -1
  104. package/dist/storage/upload-limits.test.d.ts +0 -2
  105. package/dist/storage/upload-limits.test.d.ts.map +0 -1
  106. package/dist/storage/upload-limits.test.js +0 -31
  107. package/dist/storage/upload-limits.test.js.map +0 -1
  108. package/dist/store/conformance.test.d.ts +0 -2
  109. package/dist/store/conformance.test.d.ts.map +0 -1
  110. package/dist/store/conformance.test.js +0 -15
  111. package/dist/store/conformance.test.js.map +0 -1
  112. package/dist/store/firebase.test.d.ts +0 -2
  113. package/dist/store/firebase.test.d.ts.map +0 -1
  114. package/dist/store/firebase.test.js +0 -103
  115. package/dist/store/firebase.test.js.map +0 -1
  116. package/dist/store/query.test.d.ts +0 -2
  117. package/dist/store/query.test.d.ts.map +0 -1
  118. package/dist/store/query.test.js +0 -89
  119. package/dist/store/query.test.js.map +0 -1
  120. package/dist/store/sqlite.test.d.ts +0 -2
  121. package/dist/store/sqlite.test.d.ts.map +0 -1
  122. package/dist/store/sqlite.test.js +0 -177
  123. package/dist/store/sqlite.test.js.map +0 -1
  124. package/dist/task-state.test.d.ts +0 -2
  125. package/dist/task-state.test.d.ts.map +0 -1
  126. package/dist/task-state.test.js +0 -423
  127. package/dist/task-state.test.js.map +0 -1
  128. package/dist/use-case-validation.test.d.ts +0 -2
  129. package/dist/use-case-validation.test.d.ts.map +0 -1
  130. package/dist/use-case-validation.test.js +0 -281
  131. package/dist/use-case-validation.test.js.map +0 -1
  132. package/dist/utils/fractional-index.test.d.ts +0 -2
  133. package/dist/utils/fractional-index.test.d.ts.map +0 -1
  134. package/dist/utils/fractional-index.test.js +0 -182
  135. package/dist/utils/fractional-index.test.js.map +0 -1
  136. package/dist/utils/key.test.d.ts +0 -2
  137. package/dist/utils/key.test.d.ts.map +0 -1
  138. package/dist/utils/key.test.js +0 -65
  139. package/dist/utils/key.test.js.map +0 -1
  140. package/dist/utils/mentions.test.d.ts +0 -2
  141. package/dist/utils/mentions.test.d.ts.map +0 -1
  142. package/dist/utils/mentions.test.js +0 -114
  143. package/dist/utils/mentions.test.js.map +0 -1
  144. package/dist/webhooks/webhooks.test.d.ts +0 -2
  145. package/dist/webhooks/webhooks.test.d.ts.map +0 -1
  146. package/dist/webhooks/webhooks.test.js +0 -154
  147. package/dist/webhooks/webhooks.test.js.map +0 -1
  148. package/src/deliverables.test.ts +0 -284
  149. package/src/domain/authorization.ts +0 -82
  150. package/src/domain/calendar.test.ts +0 -216
  151. package/src/domain/calendar.ts +0 -502
  152. package/src/domain/cpm.test.ts +0 -189
  153. package/src/domain/cpm.ts +0 -258
  154. package/src/domain/custom-fields.ts +0 -172
  155. package/src/domain/ddd.test.ts +0 -442
  156. package/src/domain/entities.ts +0 -798
  157. package/src/domain/errors.ts +0 -38
  158. package/src/domain/events.ts +0 -369
  159. package/src/domain/graph.ts +0 -70
  160. package/src/domain/index.ts +0 -12
  161. package/src/domain/ladder.test.ts +0 -251
  162. package/src/domain/ladder.ts +0 -474
  163. package/src/domain/metrics.test.ts +0 -288
  164. package/src/domain/metrics.ts +0 -497
  165. package/src/domain/task-duration.test.ts +0 -354
  166. package/src/domain/workload.test.ts +0 -311
  167. package/src/domain/workload.ts +0 -457
  168. package/src/engine/actor.test.ts +0 -63
  169. package/src/engine/authorization.test.ts +0 -199
  170. package/src/engine/cascade.test.ts +0 -121
  171. package/src/engine/config.test.ts +0 -51
  172. package/src/engine/entity-events.test.ts +0 -67
  173. package/src/engine/index.ts +0 -2495
  174. package/src/index.test.ts +0 -514
  175. package/src/index.ts +0 -9
  176. package/src/plugins/index.ts +0 -103
  177. package/src/plugins/plugins.test.ts +0 -161
  178. package/src/schemas/index.ts +0 -402
  179. package/src/schemas/schemas.test.ts +0 -116
  180. package/src/status.test.ts +0 -354
  181. package/src/storage/file-storage.ts +0 -200
  182. package/src/storage/firebase-storage.ts +0 -209
  183. package/src/storage/index.ts +0 -3
  184. package/src/storage/s3.ts +0 -151
  185. package/src/storage/storage.test.ts +0 -274
  186. package/src/storage/upload-limits.test.ts +0 -41
  187. package/src/store/conformance.test.ts +0 -15
  188. package/src/store/firebase.test.ts +0 -131
  189. package/src/store/firebase.ts +0 -658
  190. package/src/store/index.ts +0 -623
  191. package/src/store/query.test.ts +0 -110
  192. package/src/store/query.ts +0 -113
  193. package/src/store/sqlite.test.ts +0 -218
  194. package/src/store/sqlite.ts +0 -1480
  195. package/src/task-state.test.ts +0 -506
  196. package/src/testing/conformance.ts +0 -319
  197. package/src/testing/index.ts +0 -1
  198. package/src/types/index.ts +0 -871
  199. package/src/use-case-validation.test.ts +0 -324
  200. package/src/utils/fractional-index.test.ts +0 -228
  201. package/src/utils/fractional-index.ts +0 -254
  202. package/src/utils/index.ts +0 -7
  203. package/src/utils/key.test.ts +0 -76
  204. package/src/utils/key.ts +0 -62
  205. package/src/utils/mentions.test.ts +0 -132
  206. package/src/utils/mentions.ts +0 -131
  207. package/src/utils/status.ts +0 -419
  208. package/src/utils/workflow.ts +0 -306
  209. package/src/webhooks/dispatcher.ts +0 -190
  210. package/src/webhooks/index.ts +0 -2
  211. package/src/webhooks/signature.ts +0 -57
  212. package/src/webhooks/webhooks.test.ts +0 -201
  213. package/tsconfig.json +0 -8
  214. package/tsconfig.tsbuildinfo +0 -1
@@ -1,2495 +0,0 @@
1
- import type {
2
- Activity,
3
- User,
4
- Actor,
5
- AuthorizationAction,
6
- CriticalPathConfig,
7
- Project,
8
- Task,
9
- TaskDependency,
10
- TaskDependencyGraph,
11
- Team,
12
- TaskContainer,
13
- Iteration,
14
- Comment,
15
- Attachment,
16
- FileStorageAdapter,
17
- UploadFileInput,
18
- PresignedUrlOptions,
19
- PresignedUploadResult,
20
- TimeEntry,
21
- Webhook,
22
- PublicWebhook,
23
- Workflow,
24
- CreateTaskInput,
25
- CreateProjectInput,
26
- Deliverable,
27
- DeliverableSummary,
28
- CreateDeliverableInput,
29
- TaskLifecycleState,
30
- CriticalPathAnalysis,
31
- TimelineLadder,
32
- TimelineLadderOptions,
33
- TaskLadderView,
34
- MacroPhaseRollup,
35
- StandardTaskTimelineItem,
36
- ConcreteTaskEvidence,
37
- TaskMetrics,
38
- TaskProgressHistory,
39
- WorkloadDistribution,
40
- WorkloadDistributionOptions
41
- } from '../types/index.js';
42
- import { StorageAdapter, InMemoryStore, type ActivityQuery, type Page, type TaskQuery } from '../store/index.js';
43
- import { PluginRegistry } from '../plugins/index.js';
44
- import { deriveTaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
45
- import {
46
- validateTransition,
47
- getAllowedNextStatuses,
48
- getAllowedPreviousStatuses,
49
- getAllowedTransitions,
50
- WorkflowValidationError,
51
- DEFAULT_SOFTWARE_WORKFLOW
52
- } from '../utils/workflow.js';
53
- import { extractMentions } from '../utils/mentions.js';
54
- import {
55
- DomainEventBus,
56
- TaskCreatedEvent,
57
- TaskUpdatedEvent,
58
- TaskStatusChangedEvent,
59
- TaskBlockedEvent,
60
- TaskUnblockedEvent,
61
- TaskDeletedEvent,
62
- ProjectCreatedEvent,
63
- ProjectUpdatedEvent,
64
- ProjectDeletedEvent,
65
- TaskDependencyRemovedEvent,
66
- TeamUpdatedEvent,
67
- TeamDeletedEvent,
68
- ContainerUpdatedEvent,
69
- ContainerDeletedEvent,
70
- IterationCreatedEvent,
71
- IterationUpdatedEvent,
72
- IterationDeletedEvent,
73
- CriticalPathDomainEvent,
74
- WorkflowCreatedEvent,
75
- WorkflowUpdatedEvent,
76
- WorkflowDeletedEvent,
77
- IterationStartedEvent,
78
- IterationCompletedEvent,
79
- TeamCreatedEvent,
80
- ContainerCreatedEvent,
81
- DeliverableCreatedEvent,
82
- DeliverableUpdatedEvent,
83
- DeliverableStatusChangedEvent,
84
- DeliverableDeletedEvent,
85
- TaskDependencyAddedEvent,
86
- TimeLoggedEvent,
87
- CommentAddedEvent,
88
- CommentUpdatedEvent,
89
- CommentDeletedEvent,
90
- CommentReactionAddedEvent,
91
- CommentReactionRemovedEvent,
92
- AttachmentCreatedEvent,
93
- AttachmentDeletedEvent
94
- } from '../domain/events.js';
95
- import { validateAttachmentUrl, AttachmentValidationError } from '../domain/entities.js';
96
- import { validateCustomFieldValues, validateCustomFieldDefinitions } from '../domain/custom-fields.js';
97
- import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
98
- import { ValidationError, NotFoundError, ForbiddenError } from '../domain/errors.js';
99
- import { WebhookDispatcher, assertWebhookUrl } from '../webhooks/dispatcher.js';
100
- import { buildStorageKey, normalizeUploadData } from '../storage/file-storage.js';
101
- import { generateWebhookSecret } from '../webhooks/signature.js';
102
- import type { DomainEvent } from '../domain/events.js';
103
- import { calculateCPM, type CPMOptions } from '../domain/cpm.js';
104
- import { buildTimelineLadder, aggregateConcreteEvidenceForTask } from '../domain/ladder.js';
105
- import {
106
- calculateTaskMetrics,
107
- reconstructTaskProgressHistory,
108
- type MetricOptions
109
- } from '../domain/metrics.js';
110
- import { calculateWorkloadDistribution } from '../domain/workload.js';
111
-
112
- export class CriticalPathEngine {
113
- public readonly config: CriticalPathConfig;
114
- public readonly store: StorageAdapter;
115
- public readonly fileStorage?: FileStorageAdapter;
116
- public readonly plugins: PluginRegistry;
117
- public readonly events: DomainEventBus;
118
- /** Delivers domain events to registered webhooks. Exposed for custom queues (`deliver`) and tests (`idle`). */
119
- public readonly webhooks: WebhookDispatcher;
120
- public readonly ready: Promise<void> = Promise.resolve();
121
- /** The identity mutations are attributed to. Set only on views returned by `withActor`. */
122
- public readonly actor?: Actor;
123
- /** Set on internal views that keep the actor for attribution but skip authorization. */
124
- private readonly authorizationBypassed?: boolean;
125
-
126
- constructor(config: CriticalPathConfig = {}) {
127
- this.config = config;
128
- if (config.store !== undefined && (typeof config.store !== 'object' || config.store === null)) {
129
- // Strings like 'sqlite' used to fall back to an in-memory store silently.
130
- throw new Error(
131
- `CriticalPathEngine "store" must be a storage adapter instance, e.g. new SQLiteStore({ filename: 'app.db' }). Received ${JSON.stringify(config.store)}.`
132
- );
133
- }
134
- this.store = config.store ?? new InMemoryStore();
135
-
136
- this.fileStorage = config.fileStorage;
137
- this.plugins = new PluginRegistry();
138
- this.events = new DomainEventBus();
139
- this.webhooks = new WebhookDispatcher(
140
- {
141
- listWebhooks: () => this.listAllWebhooks(),
142
- resolveTenant: (event) => this.resolveEventTenant(event)
143
- },
144
- config.webhookDelivery
145
- );
146
- this.events.subscribe('*', (event) => this.webhooks.handle(event));
147
-
148
- if (config.plugins) {
149
- for (const plugin of config.plugins) {
150
- this.plugins.register(plugin);
151
- }
152
- }
153
-
154
- this.ready = this.initialize(config);
155
- // Callers that never await `ready` should not crash on an unhandled rejection; awaiting it still throws.
156
- this.ready.catch(() => {});
157
- }
158
-
159
- /**
160
- * Returns a view of this engine that attributes every mutation to `actor`: activity log
161
- * entries, comment authors, reactions, time entries, attachment uploaders and task reporters.
162
- * Identity fields supplied in payloads are ignored on the view, so callers cannot claim to be
163
- * someone else. The view shares the store, plugins and event bus, and does not modify this engine,
164
- * so it is safe to create one per request.
165
- */
166
- withActor(actor: Actor): CriticalPathEngine {
167
- const scoped = Object.create(this) as CriticalPathEngine;
168
- Object.defineProperty(scoped, 'actor', { value: Object.freeze({ ...actor }), enumerable: true });
169
- return scoped;
170
- }
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
-
181
- private actorIdOr(fallback: string): string {
182
- return this.actor?.userId ?? fallback;
183
- }
184
-
185
- // --- Authorization (applies only to withActor views; the base engine is trusted) ---
186
-
187
- /**
188
- * True when calls must be checked: on a withActor view (not an internal elevated view) with a
189
- * policy or a tenant to enforce. Otherwise there is nothing to check, so lookups are skipped.
190
- */
191
- private get enforcing(): boolean {
192
- return !!this.actor && !this.authorizationBypassed && (!!this.config.authorize || !!this.actor.tenantId);
193
- }
194
-
195
- /** A view that keeps the actor for attribution but skips checks, for cascades already authorized. */
196
- private elevated(): CriticalPathEngine {
197
- if (!this.enforcing) return this;
198
- const view = Object.create(this) as CriticalPathEngine;
199
- Object.defineProperty(view, 'authorizationBypassed', { value: true });
200
- return view;
201
- }
202
-
203
- private inActorTenant(entity: { tenantId?: string } | null | undefined): boolean {
204
- const tenant = this.actor?.tenantId;
205
- return !tenant || entity?.tenantId === tenant;
206
- }
207
-
208
- private async isAllowed(
209
- action: AuthorizationAction,
210
- project?: Project,
211
- resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
212
- ): Promise<boolean> {
213
- if (!this.enforcing) return true;
214
- if (project && !this.inActorTenant(project)) return false;
215
- const policy = this.config.authorize;
216
- return policy ? await policy({ actor: this.actor!, action, project, resource }) : true;
217
- }
218
-
219
- /** The project if the actor may read it, otherwise null (callers report "not found"). */
220
- private async readableProject(projectId: string | undefined): Promise<Project | null> {
221
- if (!projectId) return null;
222
- const project = await this.store.getProject(projectId);
223
- if (!project) return null;
224
- return (await this.isAllowed('project.read', project)) ? project : null;
225
- }
226
-
227
- private async canReadProject(projectId: string | undefined): Promise<boolean> {
228
- if (!this.enforcing) return true;
229
- return (await this.readableProject(projectId)) !== null;
230
- }
231
-
232
- /**
233
- * Ensures the actor may perform `action` in `projectId`. Projects the actor cannot read (or
234
- * that belong to another tenant) are reported as not found so their existence is not revealed.
235
- */
236
- private async requireProjectAccess(
237
- action: AuthorizationAction,
238
- projectId: string | undefined,
239
- resource?: { type: 'task' | 'comment' | 'attachment'; ownerId?: string }
240
- ): Promise<void> {
241
- if (!this.enforcing) return;
242
- const project = await this.readableProject(projectId);
243
- if (!project) throw new NotFoundError(`Project "${projectId}" not found.`);
244
- if (action !== 'project.read' && !(await this.isAllowed(action, project, resource))) {
245
- throw new ForbiddenError(`Not allowed to ${action} in project "${projectId}".`);
246
- }
247
- }
248
-
249
- private async requireWorkspaceAccess(action: 'project.create' | 'workspace.manage'): Promise<void> {
250
- if (!(await this.isAllowed(action))) throw new ForbiddenError(`Not allowed to ${action}.`);
251
- }
252
-
253
- /** Keeps only items in projects the actor may read. Items with no project are hidden on views. */
254
- private async filterReadable<T>(
255
- items: T[],
256
- projectIdOf: (item: T) => string | undefined | Promise<string | undefined>
257
- ): Promise<T[]> {
258
- if (!this.enforcing) return items;
259
- const readable = new Map<string, boolean>();
260
- const result: T[] = [];
261
- for (const item of items) {
262
- const projectId = await projectIdOf(item);
263
- if (!projectId) continue;
264
- if (!readable.has(projectId)) readable.set(projectId, await this.canReadProject(projectId));
265
- if (readable.get(projectId)) result.push(item);
266
- }
267
- return result;
268
- }
269
-
270
- private async projectIdOfTask(taskId: string | undefined): Promise<string | undefined> {
271
- return taskId ? (await this.store.getTask(taskId))?.projectId : undefined;
272
- }
273
-
274
- private async projectIdOfComment(commentId: string | undefined): Promise<string | undefined> {
275
- if (!commentId) return undefined;
276
- const comment = await this.store.getComment(commentId);
277
- return this.projectIdOfTask(comment?.taskId);
278
- }
279
-
280
- private async projectIdOfAttachment(a: { projectId?: string; taskId?: string; commentId?: string }): Promise<string | undefined> {
281
- return a.projectId ?? (await this.projectIdOfTask(a.taskId)) ?? (await this.projectIdOfComment(a.commentId));
282
- }
283
-
284
- /** Strips fields only the engine may set (tenancy) from updates made through a view. */
285
- private withoutTenant<T extends { tenantId?: string }>(updates: T): T {
286
- if (!this.actor || !('tenantId' in updates)) return updates;
287
- const { tenantId: _ignored, ...rest } = updates;
288
- return rest as T;
289
- }
290
-
291
- private async initialize(config: CriticalPathConfig): Promise<void> {
292
- if (config.initialData) {
293
- await this.seedInitialData(config.initialData);
294
- }
295
- for (const plugin of this.plugins.getPlugins()) {
296
- await plugin.init?.(this);
297
- }
298
- }
299
-
300
- private async seedInitialData(data: NonNullable<CriticalPathConfig['initialData']>): Promise<void> {
301
- if (data.workflows) {
302
- for (const wf of data.workflows) {
303
- await this.store.createWorkflow(wf);
304
- }
305
- }
306
- if (data.projects) {
307
- for (const p of data.projects) {
308
- await this.store.createProject(p);
309
- }
310
- }
311
- if (data.teams) {
312
- for (const tm of data.teams) {
313
- await this.store.createTeam(tm);
314
- }
315
- }
316
- if (data.containers) {
317
- for (const c of data.containers) {
318
- await this.store.createContainer(c);
319
- }
320
- }
321
- if (data.deliverables) {
322
- for (const d of data.deliverables) {
323
- await this.store.createDeliverable(d);
324
- }
325
- }
326
- if (data.iterations) {
327
- for (const it of data.iterations) {
328
- await this.store.createIteration(it);
329
- }
330
- }
331
- if (data.tasks) {
332
- for (const t of data.tasks) {
333
- await this.store.createTask(t);
334
- }
335
- }
336
- }
337
-
338
- // --- Workflows ---
339
- async getWorkflows(): Promise<Workflow[]> {
340
- const workflows = await this.store.getWorkflows();
341
- return workflows.filter((w) => this.inActorTenant(w));
342
- }
343
-
344
- async getWorkflow(id: string): Promise<Workflow | null> {
345
- const workflow = await this.store.getWorkflow(id);
346
- return workflow && this.inActorTenant(workflow) ? workflow : null;
347
- }
348
-
349
- async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
350
- await this.requireWorkspaceAccess('workspace.manage');
351
- const created = await this.store.createWorkflow({ ...workflow, tenantId: this.actor ? this.actor.tenantId : workflow.tenantId });
352
- const now = new Date().toISOString();
353
-
354
- const event: WorkflowCreatedEvent = {
355
- id: `evt_${crypto.randomUUID()}`,
356
- name: 'workflow.created',
357
- aggregateId: created.id,
358
- aggregateType: 'Workflow',
359
- occurredAt: now,
360
- payload: { workflow: created }
361
- };
362
- await this.events.publish(event);
363
-
364
- await this.store.logActivity({
365
- actorId: this.actorIdOr('system'),
366
- action: 'workflow.created',
367
- details: { name: created.name }
368
- });
369
- return created;
370
- }
371
-
372
- async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
373
- const existing = await this.getWorkflow(id);
374
- if (!existing) return null;
375
- await this.requireWorkspaceAccess('workspace.manage');
376
- updates = this.withoutTenant(updates);
377
-
378
- const updated = await this.store.updateWorkflow(id, updates);
379
- if (updated) {
380
- const now = new Date().toISOString();
381
- const event: WorkflowUpdatedEvent = {
382
- id: `evt_${crypto.randomUUID()}`,
383
- name: 'workflow.updated',
384
- aggregateId: updated.id,
385
- aggregateType: 'Workflow',
386
- occurredAt: now,
387
- payload: { workflow: updated, previous: existing }
388
- };
389
- await this.events.publish(event);
390
-
391
- await this.store.logActivity({
392
- actorId: this.actorIdOr('system'),
393
- action: 'workflow.updated',
394
- details: { name: updated.name }
395
- });
396
- }
397
- return updated;
398
- }
399
-
400
- async deleteWorkflow(id: string): Promise<boolean> {
401
- const existing = await this.getWorkflow(id);
402
- if (!existing) return false;
403
- await this.requireWorkspaceAccess('workspace.manage');
404
-
405
- const deleted = await this.store.deleteWorkflow(id);
406
- if (deleted) {
407
- const now = new Date().toISOString();
408
- const event: WorkflowDeletedEvent = {
409
- id: `evt_${crypto.randomUUID()}`,
410
- name: 'workflow.deleted',
411
- aggregateId: id,
412
- aggregateType: 'Workflow',
413
- occurredAt: now,
414
- payload: { workflowId: id, name: existing.name, tenantId: existing.tenantId }
415
- };
416
- await this.events.publish(event);
417
-
418
- await this.store.logActivity({
419
- actorId: this.actorIdOr('system'),
420
- action: 'workflow.deleted',
421
- details: { name: existing.name }
422
- });
423
- }
424
- return deleted;
425
- }
426
-
427
- async resolveProjectWorkflow(projectId: string): Promise<Workflow | null> {
428
- const project = await this.store.getProject(projectId);
429
- if (project?.workflow) return project.workflow;
430
- if (project?.workflowId) {
431
- const wf = await this.store.getWorkflow(project.workflowId);
432
- if (wf) return wf;
433
- }
434
-
435
- // Only fall back to workflows from the project's own tenant.
436
- const workflows = (await this.store.getWorkflows()).filter((w) => w.tenantId === project?.tenantId);
437
- const defaultWf = workflows.find((w) => w.isDefault);
438
- if (defaultWf) return defaultWf;
439
- if (workflows.length > 0) return workflows[0];
440
-
441
- return DEFAULT_SOFTWARE_WORKFLOW;
442
- }
443
-
444
- async getAllowedTaskTransitions(taskId: string): Promise<string[]> {
445
- const task = await this.getTask(taskId);
446
- if (!task) return [];
447
- const workflow = await this.resolveProjectWorkflow(task.projectId);
448
- return getAllowedTransitions(workflow || undefined, task.status);
449
- }
450
-
451
- async getAllowedNextTaskTransitions(taskId: string): Promise<string[]> {
452
- const task = await this.getTask(taskId);
453
- if (!task) return [];
454
- const workflow = await this.resolveProjectWorkflow(task.projectId);
455
- return getAllowedNextStatuses(workflow || undefined, task.status);
456
- }
457
-
458
- async getAllowedPreviousTaskTransitions(taskId: string): Promise<string[]> {
459
- const task = await this.getTask(taskId);
460
- if (!task) return [];
461
- const workflow = await this.resolveProjectWorkflow(task.projectId);
462
- return getAllowedPreviousStatuses(workflow || undefined, task.status);
463
- }
464
-
465
- // --- Projects ---
466
- async getProjects(): Promise<Project[]> {
467
- const projects = await this.store.getProjects();
468
- return this.filterReadable(projects, (p) => p.id);
469
- }
470
-
471
- async getProject(id: string): Promise<Project | null> {
472
- if (!this.enforcing) return this.store.getProject(id);
473
- return this.readableProject(id);
474
- }
475
-
476
- async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
477
- await this.requireWorkspaceAccess('project.create');
478
- validateCustomFieldDefinitions(project.customFieldDefinitions, this.plugins.getCustomFieldTypes());
479
- // Views always use the actor's tenant (possibly none); only trusted base-engine calls may set it.
480
- const input = { ...project, tenantId: this.actor ? this.actor.tenantId : project.tenantId };
481
- // The creator administers the project they create.
482
- if (this.actor && this.actor.userId !== 'anonymous' && !input.members?.some((m) => m.userId === this.actor!.userId)) {
483
- input.members = [...(input.members ?? []), { userId: this.actor.userId, role: 'admin' }];
484
- }
485
- const created = await this.store.createProject(input);
486
- const now = new Date().toISOString();
487
-
488
- const event: ProjectCreatedEvent = {
489
- id: `evt_${crypto.randomUUID()}`,
490
- name: 'project.created',
491
- aggregateId: created.id,
492
- aggregateType: 'Project',
493
- occurredAt: now,
494
- payload: { project: created }
495
- };
496
- await this.events.publish(event);
497
-
498
- await this.store.logActivity({
499
- projectId: created.id,
500
- actorId: this.actorIdOr(project.ownerId || 'system'),
501
- action: 'project.created',
502
- details: { name: created.name, key: created.key }
503
- });
504
- return created;
505
- }
506
-
507
- async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
508
- const existing = await this.getProject(id);
509
- if (!existing) return null;
510
- await this.requireProjectAccess('project.update', id);
511
- if ('members' in updates) await this.requireProjectAccess('project.manage_members', id);
512
- validateCustomFieldDefinitions(updates.customFieldDefinitions, this.plugins.getCustomFieldTypes());
513
- updates = this.withoutTenant(updates);
514
-
515
- const updated = await this.store.updateProject(id, updates);
516
- if (updated) {
517
- const now = new Date().toISOString();
518
- const event: ProjectUpdatedEvent = {
519
- id: `evt_${crypto.randomUUID()}`,
520
- name: 'project.updated',
521
- aggregateId: updated.id,
522
- aggregateType: 'Project',
523
- occurredAt: now,
524
- payload: { project: updated, previous: existing }
525
- };
526
- await this.events.publish(event);
527
-
528
- await this.store.logActivity({
529
- projectId: updated.id,
530
- actorId: this.actorIdOr('system'),
531
- action: 'project.updated',
532
- details: { name: updated.name }
533
- });
534
- }
535
- return updated;
536
- }
537
-
538
- /**
539
- * Deletes a project and its tasks. Each task goes through `deleteTask`, so plugin
540
- * hooks run and `task.deleted` events fire before `project.deleted` is published.
541
- */
542
- async deleteProject(id: string): Promise<boolean> {
543
- const existing = await this.getProject(id);
544
- if (!existing) return false;
545
- await this.requireProjectAccess('project.delete', id);
546
- // Deleting the project implies deleting its tasks; skip per-task checks for the cascade.
547
- const cascade = this.elevated();
548
-
549
- const tasks = await this.store.getTasks(id);
550
- for (const task of tasks) {
551
- // Subtasks may already be gone with their parent; deleteTask then returns false.
552
- await cascade.deleteTask(task.id);
553
- }
554
- const deletedTaskIds = (
555
- await Promise.all(tasks.map(async (t) => ((await this.store.getTask(t.id)) ? null : t.id)))
556
- ).filter((taskId): taskId is string => taskId !== null);
557
-
558
- for (const container of await this.store.getContainers(id)) await this.store.deleteContainer(container.id);
559
- for (const iteration of await this.store.getIterations(id)) await this.store.deleteIteration(iteration.id);
560
- for (const deliverable of await this.store.getDeliverables(id)) await cascade.deleteDeliverable(deliverable.id);
561
- for (const attachment of await this.store.getAttachments({ projectId: id })) await cascade.deleteAttachment(attachment.id);
562
-
563
- const deleted = await this.store.deleteProject(id);
564
- if (deleted) {
565
- const now = new Date().toISOString();
566
- const event: ProjectDeletedEvent = {
567
- id: `evt_${crypto.randomUUID()}`,
568
- name: 'project.deleted',
569
- aggregateId: id,
570
- aggregateType: 'Project',
571
- occurredAt: now,
572
- payload: { projectId: id, name: existing.name, deletedTaskIds, tenantId: existing.tenantId }
573
- };
574
- await this.events.publish(event);
575
-
576
- await this.store.logActivity({
577
- projectId: id,
578
- actorId: this.actorIdOr('system'),
579
- action: 'project.deleted',
580
- details: { name: existing.name, deletedTaskCount: deletedTaskIds.length }
581
- });
582
- }
583
- return deleted;
584
- }
585
-
586
- // --- Tasks ---
587
- async getTasks(projectId?: string): Promise<Task[]> {
588
- if (projectId && !(await this.canReadProject(projectId))) return [];
589
- const tasks = await this.store.getTasks(projectId);
590
- return projectId ? tasks : this.filterReadable(tasks, (t) => t.projectId);
591
- }
592
-
593
- /**
594
- * Filtered, paginated tasks (oldest first). Without `projectId`, results span every project
595
- * the actor can read.
596
- */
597
- async queryTasks(query: TaskQuery = {}): Promise<Page<Task>> {
598
- if (query.projectId) {
599
- return (await this.canReadProject(query.projectId)) ? this.store.queryTasks(query) : { items: [] };
600
- }
601
- if (!this.enforcing) return this.store.queryTasks(query);
602
- return this.store.queryTasks({ ...query, projectIds: await this.readableProjectIds(query.projectIds) });
603
- }
604
-
605
- /** Paginated activity feed (newest first), limited to projects the actor can read. */
606
- async queryActivities(query: ActivityQuery = {}): Promise<Page<Activity>> {
607
- if (query.taskId) {
608
- return (await this.getTask(query.taskId)) ? this.store.queryActivities(query) : { items: [] };
609
- }
610
- if (query.projectId) {
611
- return (await this.canReadProject(query.projectId)) ? this.store.queryActivities(query) : { items: [] };
612
- }
613
- if (!this.enforcing) return this.store.queryActivities(query);
614
- return this.store.queryActivities({ ...query, projectIds: await this.readableProjectIds(query.projectIds) });
615
- }
616
-
617
- private async readableProjectIds(requested?: string[]): Promise<string[]> {
618
- const readable = (await this.getProjects()).map((p) => p.id);
619
- return requested ? requested.filter((id) => readable.includes(id)) : readable;
620
- }
621
-
622
- async getTask(id: string): Promise<Task | null> {
623
- const task = await this.store.getTask(id);
624
- if (!task) return null;
625
- return (await this.canReadProject(task.projectId)) ? task : null;
626
- }
627
-
628
- async createTask(taskInput: CreateTaskInput): Promise<Task> {
629
- await this.requireProjectAccess('task.create', taskInput.projectId);
630
- if (this.actor && !taskInput.reporterId) {
631
- taskInput = { ...taskInput, reporterId: this.actor.userId };
632
- }
633
- const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
634
- if (processedInput.projectId && processedInput.projectId !== taskInput.projectId) {
635
- throw new ValidationError('Plugins cannot move a task to another project in beforeTaskCreate.');
636
- }
637
- const projectId = taskInput.projectId;
638
- const project = await this.store.getProject(projectId);
639
- const workflow = await this.resolveProjectWorkflow(projectId);
640
-
641
- // Validate custom fields on the final (post-plugin) input, including required fields when none are given
642
- validateCustomFieldValues(
643
- project?.customFieldDefinitions,
644
- processedInput.customFields ?? taskInput.customFields,
645
- this.plugins.getCustomFieldTypes()
646
- );
647
-
648
- const defaultStatus = workflow?.defaultStatusKey || 'todo';
649
- const initialStatus = processedInput.status || taskInput.status || defaultStatus;
650
- const now = new Date().toISOString();
651
-
652
- // Derive initial lifecycle timestamps and semantic status
653
- const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
654
- const semanticStatus = processedInput.semanticStatus ?? taskInput.semanticStatus ?? statusDef.category;
655
- const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.category === 'in_progress' ? now : undefined);
656
- const inProgressSince = processedInput.inProgressSince ?? taskInput.inProgressSince ?? (statusDef.category === 'in_progress' ? now : undefined);
657
- const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? ((statusDef.category === 'completed' || statusDef.category === 'canceled') ? now : undefined);
658
- const completedAt = processedInput.completedAt ?? taskInput.completedAt ?? (statusDef.category === 'completed' ? now : undefined);
659
-
660
- const isInitialBlocked = processedInput.isBlocked ?? taskInput.isBlocked ?? false;
661
- const blockedSince = processedInput.blockedSince ?? taskInput.blockedSince ?? (isInitialBlocked && statusDef.category === 'in_progress' ? now : undefined);
662
- const blockedDurationSeconds = processedInput.blockedDurationSeconds ?? taskInput.blockedDurationSeconds;
663
-
664
- const created = await this.store.createTask({
665
- projectId,
666
- title: processedInput.title || taskInput.title,
667
- description: processedInput.description ?? taskInput.description,
668
- status: initialStatus,
669
- semanticStatus,
670
- priority: processedInput.priority || taskInput.priority || 'medium',
671
- taskType: processedInput.taskType || taskInput.taskType || 'task',
672
- assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
673
- assignees: processedInput.assignees ?? taskInput.assignees,
674
- reporterId: processedInput.reporterId ?? taskInput.reporterId,
675
- reviewerId: processedInput.reviewerId ?? taskInput.reviewerId,
676
- iterationId: processedInput.iterationId ?? taskInput.iterationId,
677
- teamId: processedInput.teamId ?? taskInput.teamId,
678
- containerId: processedInput.containerId ?? taskInput.containerId,
679
- deliverableId: processedInput.deliverableId ?? taskInput.deliverableId,
680
- plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
681
- actualStartDate,
682
- actualEndDate,
683
- completedAt,
684
- dueDate: processedInput.dueDate ?? taskInput.dueDate,
685
- estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
686
- loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
687
- actualHours: processedInput.actualHours ?? taskInput.actualHours,
688
- billableHours: processedInput.billableHours ?? taskInput.billableHours,
689
- estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
690
- actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
691
- billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
692
- actualDurationSeconds: processedInput.actualDurationSeconds ?? taskInput.actualDurationSeconds,
693
- inProgressSince,
694
- blockedDurationSeconds,
695
- blockedSince,
696
- progress: processedInput.progress ?? taskInput.progress ?? (statusDef.category === 'completed' ? 100 : 0),
697
- isBlocked: isInitialBlocked,
698
- blockedReason: processedInput.blockedReason ?? taskInput.blockedReason ?? null,
699
- tags: processedInput.tags ?? taskInput.tags ?? [],
700
- customFields: processedInput.customFields ?? taskInput.customFields ?? {},
701
- parentId: processedInput.parentId ?? taskInput.parentId
702
- });
703
-
704
- await this.plugins.runAfterTaskCreate(created);
705
-
706
- // Publish typed Domain Event
707
- const event: TaskCreatedEvent = {
708
- id: `evt_${crypto.randomUUID()}`,
709
- name: 'task.created',
710
- aggregateId: created.id,
711
- aggregateType: 'Task',
712
- occurredAt: now,
713
- payload: { task: created }
714
- };
715
- await this.events.publish(event);
716
-
717
- await this.store.logActivity({
718
- projectId: created.projectId,
719
- taskId: created.id,
720
- actorId: this.actorIdOr(created.reporterId || 'system'),
721
- action: 'task.created',
722
- details: { title: created.title, status: created.status }
723
- });
724
-
725
- return created;
726
- }
727
-
728
- async updateTask(
729
- id: string,
730
- updates: Partial<Task>,
731
- options?: {
732
- actorId?: string;
733
- actorName?: string;
734
- actorType?: string;
735
- actor?: { userId: string; username?: string; actorType?: string };
736
- }
737
- ): Promise<Task | null> {
738
- const existing = await this.getTask(id);
739
- if (!existing) return null;
740
- await this.requireProjectAccess('task.update', existing.projectId);
741
-
742
- // Identity comes from trusted options or the withActor view, never from the update payload.
743
- options = options ?? (this.actor ? { actor: this.actor } : undefined);
744
- const actorId = options?.actorId || options?.actor?.userId || 'system';
745
- const actorName =
746
- options?.actorName || options?.actor?.username || (actorId === 'system' ? 'System' : undefined);
747
- const actorType =
748
- options?.actorType || options?.actor?.actorType || (actorId === 'system' ? 'system' : 'user');
749
- const actorObj = options?.actor || { userId: actorId, username: actorName, actorType };
750
- const taskUpdates = updates;
751
-
752
- const project = await this.store.getProject(existing.projectId);
753
- const workflow = await this.resolveProjectWorkflow(existing.projectId);
754
-
755
- // Plugins run first; their output is then validated like caller input. Identity and
756
- // ownership fields are never changed by hooks.
757
- const {
758
- id: _id,
759
- projectId: _projectId,
760
- createdAt: _createdAt,
761
- ...processedUpdates
762
- } = await this.plugins.runBeforeTaskUpdate(id, taskUpdates) as Partial<Task>;
763
-
764
- // Validate workflow transition invariant
765
- if (processedUpdates.status && processedUpdates.status !== existing.status) {
766
- const isValid = validateTransition(workflow || undefined, existing.status, processedUpdates.status);
767
- if (!isValid) {
768
- throw new WorkflowValidationError(existing.status, processedUpdates.status, workflow?.id);
769
- }
770
- }
771
-
772
- // Validate custom field invariants
773
- if (processedUpdates.customFields) {
774
- validateCustomFieldValues(
775
- project?.customFieldDefinitions,
776
- { ...existing.customFields, ...processedUpdates.customFields },
777
- this.plugins.getCustomFieldTypes()
778
- );
779
- }
780
-
781
- const now = new Date().toISOString();
782
- const existingStatusDef = resolveStatusDefinition(
783
- existing.status,
784
- project?.statusDefinitions || workflow?.statuses
785
- );
786
- const newStatusDef = processedUpdates.status
787
- ? resolveStatusDefinition(
788
- processedUpdates.status,
789
- project?.statusDefinitions || workflow?.statuses
790
- )
791
- : undefined;
792
- const effectiveCategory = newStatusDef?.category ?? existingStatusDef.category;
793
-
794
- // Auto-update execution/completion timestamps if status changes
795
- if (processedUpdates.status && processedUpdates.status !== existing.status && newStatusDef) {
796
- processedUpdates.semanticStatus = processedUpdates.semanticStatus ?? newStatusDef.category;
797
-
798
- // 1. Leaving in_progress: finalize active session duration and blocked duration
799
- if (existingStatusDef.category === 'in_progress' && newStatusDef.category !== 'in_progress') {
800
- const inProgressSince = existing.inProgressSince;
801
- if (inProgressSince) {
802
- const startMs = new Date(inProgressSince).getTime();
803
- const endMs = new Date(now).getTime();
804
- if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
805
- const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
806
- processedUpdates.actualDurationSeconds =
807
- (processedUpdates.actualDurationSeconds ?? existing.actualDurationSeconds ?? 0) + sessionSeconds;
808
- if (processedUpdates.actualHours === undefined) {
809
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
810
- }
811
- if (processedUpdates.actualDurationMinutes === undefined) {
812
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
813
- }
814
- }
815
- processedUpdates.inProgressSince = null;
816
- }
817
-
818
- const blockedSince = processedUpdates.blockedSince ?? existing.blockedSince;
819
- if (blockedSince) {
820
- const startMs = new Date(blockedSince).getTime();
821
- const endMs = new Date(now).getTime();
822
- if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
823
- const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
824
- processedUpdates.blockedDurationSeconds =
825
- (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
826
- }
827
- processedUpdates.blockedSince = null;
828
- }
829
- }
830
-
831
- // 2. Entering in_progress: retain earliest start date & start session timer (and blocked timer if blocked)
832
- if (newStatusDef.category === 'in_progress') {
833
- if (!existing.actualStartDate && !processedUpdates.actualStartDate) {
834
- processedUpdates.actualStartDate = now;
835
- }
836
- if (!processedUpdates.inProgressSince) {
837
- processedUpdates.inProgressSince = now;
838
- }
839
- const isTaskBlocked = processedUpdates.isBlocked !== undefined
840
- ? Boolean(processedUpdates.isBlocked)
841
- : Boolean(existing.isBlocked);
842
- if (isTaskBlocked && !processedUpdates.blockedSince) {
843
- processedUpdates.blockedSince = now;
844
- }
845
- // Moving from completed/canceled to in_progress resets completion timestamp and progress
846
- if (
847
- existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled'
848
- ) {
849
- if (processedUpdates.actualEndDate === undefined) {
850
- processedUpdates.actualEndDate = undefined;
851
- }
852
- if (processedUpdates.completedAt === undefined) {
853
- processedUpdates.completedAt = undefined;
854
- }
855
- if (existing.progress === 100 && processedUpdates.progress === undefined) {
856
- processedUpdates.progress = 0;
857
- }
858
- }
859
- } else if (
860
- (existingStatusDef.category === 'completed' || existingStatusDef.category === 'canceled') &&
861
- newStatusDef.category === 'not_started'
862
- ) {
863
- // Moving from completed/canceled to not_started resets completion timestamp and progress
864
- if (processedUpdates.actualEndDate === undefined) {
865
- processedUpdates.actualEndDate = undefined;
866
- }
867
- if (processedUpdates.completedAt === undefined) {
868
- processedUpdates.completedAt = undefined;
869
- }
870
- if (existing.progress === 100 && processedUpdates.progress === undefined) {
871
- processedUpdates.progress = 0;
872
- }
873
- }
874
-
875
- // 3. Entering completed or canceled
876
- if (newStatusDef.category === 'completed') {
877
- if (!processedUpdates.actualEndDate) {
878
- processedUpdates.actualEndDate = now;
879
- }
880
- if (!processedUpdates.completedAt) {
881
- processedUpdates.completedAt = now;
882
- }
883
- if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
884
- processedUpdates.progress = 100;
885
- }
886
- } else if (newStatusDef.category === 'canceled') {
887
- if (!processedUpdates.actualEndDate) {
888
- processedUpdates.actualEndDate = now;
889
- }
890
- if (processedUpdates.completedAt === undefined) {
891
- processedUpdates.completedAt = undefined;
892
- }
893
- }
894
- }
895
-
896
- // Handle isBlocked transitions while in_progress (when in_progress, whether status changed or not)
897
- if (effectiveCategory === 'in_progress') {
898
- const wasBlocked = Boolean(existing.isBlocked);
899
- const isNowBlocked = processedUpdates.isBlocked !== undefined
900
- ? Boolean(processedUpdates.isBlocked)
901
- : wasBlocked;
902
-
903
- if (!wasBlocked && isNowBlocked) {
904
- if (!processedUpdates.blockedSince) {
905
- processedUpdates.blockedSince = now;
906
- }
907
- } else if (wasBlocked && !isNowBlocked) {
908
- const blockedSince = existing.blockedSince ?? processedUpdates.blockedSince;
909
- if (blockedSince) {
910
- const startMs = new Date(blockedSince).getTime();
911
- const endMs = new Date(now).getTime();
912
- if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
913
- const sessionSeconds = Math.max(0, (endMs - startMs) / 1000);
914
- processedUpdates.blockedDurationSeconds =
915
- (processedUpdates.blockedDurationSeconds ?? existing.blockedDurationSeconds ?? 0) + sessionSeconds;
916
- }
917
- processedUpdates.blockedSince = null;
918
- }
919
- }
920
- } else if (processedUpdates.isBlocked === false && (existing.blockedSince || processedUpdates.blockedSince)) {
921
- processedUpdates.blockedSince = null;
922
- }
923
-
924
- // Mutual sync for manual duration/effort edits
925
- if (processedUpdates.actualDurationSeconds !== undefined && processedUpdates.actualHours === undefined) {
926
- processedUpdates.actualHours = Math.round((processedUpdates.actualDurationSeconds / 3600) * 100) / 100;
927
- if (processedUpdates.actualDurationMinutes === undefined) {
928
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualDurationSeconds / 60);
929
- }
930
- } else if (processedUpdates.actualHours !== undefined && processedUpdates.actualDurationSeconds === undefined) {
931
- processedUpdates.actualDurationSeconds = Math.round(processedUpdates.actualHours * 3600);
932
- if (processedUpdates.actualDurationMinutes === undefined) {
933
- processedUpdates.actualDurationMinutes = Math.round(processedUpdates.actualHours * 60);
934
- }
935
- }
936
-
937
- const updated = await this.store.updateTask(id, processedUpdates);
938
- if (!updated) return null;
939
-
940
- await this.plugins.runAfterTaskUpdate(updated, existing);
941
-
942
- const isStatusChange = existing.status !== updated.status;
943
-
944
- // Publish typed Domain Events
945
- if (isStatusChange) {
946
- const statusEvent: TaskStatusChangedEvent = {
947
- id: `evt_${crypto.randomUUID()}`,
948
- name: 'task.status_changed',
949
- aggregateId: updated.id,
950
- aggregateType: 'Task',
951
- occurredAt: now,
952
- payload: {
953
- task: updated,
954
- previousStatus: existing.status,
955
- newStatus: updated.status,
956
- actorId,
957
- ...(actorName ? { actorName } : {}),
958
- actor: actorObj
959
- }
960
- };
961
- await this.events.publish(statusEvent);
962
-
963
- const statusDef = resolveStatusDefinition(
964
- updated.status,
965
- project?.statusDefinitions || workflow?.statuses
966
- );
967
- if (statusDef.category === 'completed') {
968
- await this.checkAndUnblockDownstreamTasks(updated);
969
- }
970
- } else {
971
- const updateEvent: TaskUpdatedEvent = {
972
- id: `evt_${crypto.randomUUID()}`,
973
- name: 'task.updated',
974
- aggregateId: updated.id,
975
- aggregateType: 'Task',
976
- occurredAt: now,
977
- payload: {
978
- task: updated,
979
- previous: existing,
980
- actorId,
981
- ...(actorName ? { actorName } : {}),
982
- actor: actorObj
983
- }
984
- };
985
- await this.events.publish(updateEvent);
986
- }
987
-
988
- const wasBlocked = Boolean(existing.isBlocked);
989
- const isNowBlocked = Boolean(updated.isBlocked);
990
-
991
- if (!wasBlocked && isNowBlocked) {
992
- const blockedReason = updated.blockedReason ?? null;
993
- const blockedEvent: TaskBlockedEvent = {
994
- id: `evt_${crypto.randomUUID()}`,
995
- name: 'task.blocked',
996
- aggregateId: updated.id,
997
- aggregateType: 'Task',
998
- occurredAt: now,
999
- payload: {
1000
- task: updated,
1001
- reason: blockedReason,
1002
- actorId,
1003
- ...(actorName ? { actorName } : {}),
1004
- actor: actorObj
1005
- }
1006
- };
1007
- await this.events.publish(blockedEvent);
1008
- await this.store.logActivity({
1009
- projectId: updated.projectId,
1010
- taskId: updated.id,
1011
- actorId,
1012
- action: 'task.blocked',
1013
- details: { reason: blockedReason, ...(actorName ? { actorName } : {}) }
1014
- });
1015
- } else if (wasBlocked && !isNowBlocked) {
1016
- const unblockedEvent: TaskUnblockedEvent = {
1017
- id: `evt_${crypto.randomUUID()}`,
1018
- name: 'task.unblocked',
1019
- aggregateId: updated.id,
1020
- aggregateType: 'Task',
1021
- occurredAt: now,
1022
- payload: {
1023
- task: updated,
1024
- actorId,
1025
- ...(actorName ? { actorName } : {}),
1026
- actor: actorObj
1027
- }
1028
- };
1029
- await this.events.publish(unblockedEvent);
1030
- await this.store.logActivity({
1031
- projectId: updated.projectId,
1032
- taskId: updated.id,
1033
- actorId,
1034
- action: 'task.unblocked',
1035
- details: { ...(actorName ? { actorName } : {}) }
1036
- });
1037
- }
1038
-
1039
- await this.store.logActivity({
1040
- projectId: updated.projectId,
1041
- taskId: updated.id,
1042
- actorId,
1043
- action: isStatusChange ? 'task.status_changed' : 'task.updated',
1044
- details: {
1045
- fromStatus: existing.status,
1046
- toStatus: updated.status,
1047
- ...(actorName ? { actorName } : {})
1048
- }
1049
- });
1050
-
1051
-
1052
- return updated;
1053
- }
1054
-
1055
- private async checkAndUnblockDownstreamTasks(completedTask: Task): Promise<void> {
1056
- try {
1057
- const graph = await this.getTaskDependencyGraph(completedTask.id);
1058
- if (!graph.downstreamTasks || graph.downstreamTasks.length === 0) return;
1059
-
1060
- for (const downstream of graph.downstreamTasks) {
1061
- const downstreamProject = await this.store.getProject(downstream.projectId);
1062
- const downstreamWorkflow = await this.resolveProjectWorkflow(downstream.projectId);
1063
- const downstreamStatusDefs = downstreamProject?.statusDefinitions || downstreamWorkflow?.statuses;
1064
- const downstreamStatusDef = resolveStatusDefinition(downstream.status, downstreamStatusDefs);
1065
-
1066
- if (downstreamStatusDef.category === 'completed' || downstreamStatusDef.category === 'canceled') {
1067
- continue;
1068
- }
1069
-
1070
- const downstreamGraph = await this.getTaskDependencyGraph(downstream.id);
1071
- const allUpstreamsCompleted = downstreamGraph.upstreamTasks.every((up) => {
1072
- const upDef = resolveStatusDefinition(up.status, downstreamStatusDefs);
1073
- return upDef.category === 'completed';
1074
- });
1075
-
1076
- if (allUpstreamsCompleted) {
1077
- let updatedDownstream = downstream;
1078
- const updates: Partial<Task> = {};
1079
-
1080
- if (downstream.isBlocked || downstream.blockedReason || downstream.blockedSince) {
1081
- updates.isBlocked = false;
1082
- updates.blockedReason = null;
1083
- if (downstream.blockedSince) {
1084
- const startMs = new Date(downstream.blockedSince).getTime();
1085
- const endMs = new Date().getTime();
1086
- if (!isNaN(startMs) && !isNaN(endMs) && endMs >= startMs) {
1087
- const blockedSeconds = Math.max(0, (endMs - startMs) / 1000);
1088
- updates.blockedDurationSeconds = (downstream.blockedDurationSeconds ?? 0) + blockedSeconds;
1089
- }
1090
- updates.blockedSince = null;
1091
- }
1092
- }
1093
-
1094
- if (Object.keys(updates).length > 0) {
1095
- const res = await this.store.updateTask(downstream.id, updates);
1096
- if (res) updatedDownstream = res;
1097
- }
1098
-
1099
- const now = new Date().toISOString();
1100
- const unblockedEvent: TaskUnblockedEvent = {
1101
- id: `evt_${crypto.randomUUID()}`,
1102
- name: 'task.unblocked',
1103
- aggregateId: downstream.id,
1104
- aggregateType: 'Task',
1105
- occurredAt: now,
1106
- payload: {
1107
- task: updatedDownstream,
1108
- upstreamTaskId: completedTask.id
1109
- }
1110
- };
1111
- await this.events.publish(unblockedEvent);
1112
-
1113
- await this.store.logActivity({
1114
- projectId: downstream.projectId,
1115
- taskId: downstream.id,
1116
- actorId: this.actorIdOr('system'),
1117
- action: 'task.unblocked',
1118
- details: { unblockedByTaskId: completedTask.id }
1119
- });
1120
-
1121
- }
1122
- }
1123
- } catch (err) {
1124
- console.error(`[CriticalPathEngine] Error during auto-unblocking for task ${completedTask.id}:`, err);
1125
- }
1126
- }
1127
-
1128
- /**
1129
- * Deletes a task and everything that belongs to it: subtasks (or, with `subtasks: 'detach'`,
1130
- * their parent link), dependencies, comments, attachments (including stored files) and time
1131
- * entries. The activity log is kept as an audit trail.
1132
- */
1133
- async deleteTask(id: string, options: { subtasks?: 'delete' | 'detach' } = {}): Promise<boolean> {
1134
- const existing = await this.getTask(id);
1135
- if (!existing) return false;
1136
- await this.requireProjectAccess('task.delete', existing.projectId);
1137
-
1138
- await this.plugins.runBeforeTaskDelete(id, existing);
1139
- await this.deleteTaskChildren(existing, options.subtasks ?? 'delete');
1140
- const deleted = await this.store.deleteTask(id);
1141
-
1142
- if (deleted) {
1143
- await this.plugins.runAfterTaskDelete(id, existing);
1144
- const now = new Date().toISOString();
1145
-
1146
- const event: TaskDeletedEvent = {
1147
- id: `evt_${crypto.randomUUID()}`,
1148
- name: 'task.deleted',
1149
- aggregateId: id,
1150
- aggregateType: 'Task',
1151
- occurredAt: now,
1152
- payload: {
1153
- taskId: id,
1154
- projectId: existing.projectId,
1155
- title: existing.title
1156
- }
1157
- };
1158
- await this.events.publish(event);
1159
-
1160
- await this.store.logActivity({
1161
- projectId: existing.projectId,
1162
- taskId: id,
1163
- actorId: this.actorIdOr('system'),
1164
- action: 'task.deleted',
1165
- details: { title: existing.title }
1166
- });
1167
- }
1168
- return deleted;
1169
- }
1170
-
1171
- /** Removes records owned by a task; the task's deletion was already authorized. */
1172
- private async deleteTaskChildren(task: Task, subtasks: 'delete' | 'detach'): Promise<void> {
1173
- const cascade = this.elevated();
1174
-
1175
- const children = (await this.store.getTasks(task.projectId)).filter((t) => t.parentId === task.id);
1176
- for (const child of children) {
1177
- if (subtasks === 'delete') {
1178
- await cascade.deleteTask(child.id, { subtasks });
1179
- } else {
1180
- await this.store.updateTask(child.id, { parentId: undefined });
1181
- }
1182
- }
1183
-
1184
- for (const dependency of await this.store.getDependencies(task.id)) {
1185
- await cascade.removeDependency(dependency.id);
1186
- }
1187
-
1188
- const comments = await this.store.getComments(task.id);
1189
- const attachments = [
1190
- ...(await this.store.getAttachments({ taskId: task.id })),
1191
- ...(await Promise.all(comments.map((c) => this.store.getAttachments({ commentId: c.id })))).flat()
1192
- ];
1193
- for (const attachmentId of new Set(attachments.map((a) => a.id))) {
1194
- await cascade.deleteAttachment(attachmentId);
1195
- }
1196
- for (const comment of comments) {
1197
- await cascade.deleteComment(comment.id);
1198
- }
1199
-
1200
- for (const entry of await this.store.getTimeEntries(task.id)) {
1201
- await this.store.deleteTimeEntry(entry.id);
1202
- }
1203
- }
1204
-
1205
- /** Removes a dependency between two tasks. Requires `task.update` on the dependent task's project. */
1206
- async removeDependency(id: string): Promise<boolean> {
1207
- const dependency = await this.store.getDependency(id);
1208
- if (!dependency) return false;
1209
- if (this.enforcing) {
1210
- const task = await this.getTask(dependency.taskId);
1211
- if (!task) return false;
1212
- await this.requireProjectAccess('task.update', task.projectId);
1213
- }
1214
-
1215
- const removed = await this.store.removeDependency(id);
1216
- if (removed) {
1217
- const event: TaskDependencyRemovedEvent = {
1218
- id: `evt_${crypto.randomUUID()}`,
1219
- name: 'dependency.removed',
1220
- aggregateId: id,
1221
- aggregateType: 'Dependency',
1222
- occurredAt: new Date().toISOString(),
1223
- payload: { dependency }
1224
- };
1225
- await this.events.publish(event);
1226
- }
1227
- return removed;
1228
- }
1229
-
1230
- // --- Task Dependencies & Graph ---
1231
- async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
1232
- if (this.enforcing) {
1233
- const [task, upstream] = await Promise.all([this.getTask(dep.taskId), this.getTask(dep.dependsOnTaskId)]);
1234
- if (!task) throw new NotFoundError(`Task "${dep.taskId}" not found.`);
1235
- if (!upstream) throw new NotFoundError(`Task "${dep.dependsOnTaskId}" not found.`);
1236
- await this.requireProjectAccess('task.update', task.projectId);
1237
- }
1238
- // Enforce Directed Acyclic Graph (DAG) Invariant
1239
- const reachableDeps = await this.collectUpstreamDependencies(dep.dependsOnTaskId);
1240
- const cycleCheck = detectDependencyCycle(reachableDeps, dep);
1241
- if (cycleCheck.hasCycle) {
1242
- throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
1243
- }
1244
-
1245
- const created = await this.store.addDependency(dep);
1246
- const now = new Date().toISOString();
1247
-
1248
- const event: TaskDependencyAddedEvent = {
1249
- id: `evt_${crypto.randomUUID()}`,
1250
- name: 'dependency.added',
1251
- aggregateId: created.id,
1252
- aggregateType: 'Dependency',
1253
- occurredAt: now,
1254
- payload: { dependency: created }
1255
- };
1256
- await this.events.publish(event);
1257
-
1258
- return created;
1259
- }
1260
-
1261
- /**
1262
- * Collects every dependency edge reachable upstream from `startTaskId`. A new edge
1263
- * `taskId -> dependsOnTaskId` creates a cycle exactly when `taskId` is reachable from
1264
- * `dependsOnTaskId`, so this is the full set of edges the cycle check needs.
1265
- */
1266
- private async collectUpstreamDependencies(startTaskId: string): Promise<TaskDependency[]> {
1267
- const edges = new Map<string, TaskDependency>();
1268
- const visited = new Set<string>();
1269
- let frontier = [startTaskId];
1270
-
1271
- while (frontier.length > 0) {
1272
- const next: string[] = [];
1273
- const results = await Promise.all(frontier.map((id) => this.store.getDependencies(id)));
1274
- frontier.forEach((id) => visited.add(id));
1275
-
1276
- for (const deps of results) {
1277
- for (const d of deps) {
1278
- // getDependencies returns edges in both directions; follow only outgoing ones.
1279
- if (!visited.has(d.taskId)) continue;
1280
- edges.set(d.id, d);
1281
- if (!visited.has(d.dependsOnTaskId) && !next.includes(d.dependsOnTaskId)) {
1282
- next.push(d.dependsOnTaskId);
1283
- }
1284
- }
1285
- }
1286
- frontier = next;
1287
- }
1288
-
1289
- return Array.from(edges.values());
1290
- }
1291
-
1292
- async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
1293
- if (this.enforcing && !(await this.getTask(taskId))) {
1294
- return { taskId, upstreamTasks: [], downstreamTasks: [], dependencies: [] };
1295
- }
1296
- const dependencies = await this.store.getDependencies(taskId);
1297
- const upstreamTaskIds = new Set<string>();
1298
- const downstreamTaskIds = new Set<string>();
1299
-
1300
- for (const dep of dependencies) {
1301
- if (dep.taskId === taskId) {
1302
- upstreamTaskIds.add(dep.dependsOnTaskId);
1303
- } else if (dep.dependsOnTaskId === taskId) {
1304
- downstreamTaskIds.add(dep.taskId);
1305
- }
1306
- }
1307
-
1308
- const upstreamTasks = (
1309
- await Promise.all(Array.from(upstreamTaskIds).map((id) => this.getTask(id)))
1310
- ).filter((t): t is Task => t !== null);
1311
-
1312
- const downstreamTasks = (
1313
- await Promise.all(Array.from(downstreamTaskIds).map((id) => this.getTask(id)))
1314
- ).filter((t): t is Task => t !== null);
1315
-
1316
- return {
1317
- taskId,
1318
- upstreamTasks,
1319
- downstreamTasks,
1320
- dependencies
1321
- };
1322
- }
1323
-
1324
- async getTaskLifecycleState(
1325
- taskId: string,
1326
- options?: { referenceDate?: Date; stalledThresholdDays?: number }
1327
- ): Promise<TaskLifecycleState | null> {
1328
- const task = await this.getTask(taskId);
1329
- if (!task) return null;
1330
- const project = await this.getProject(task.projectId);
1331
- const workflow = await this.resolveProjectWorkflow(task.projectId);
1332
- const statusDefs = project?.statusDefinitions || workflow?.statuses;
1333
-
1334
- const graph = await this.getTaskDependencyGraph(taskId);
1335
- const upstreamTasks = graph.upstreamTasks;
1336
-
1337
- return deriveTaskLifecycleState(task, {
1338
- customDefinitions: statusDefs,
1339
- referenceDate: options?.referenceDate,
1340
- stalledThresholdDays: options?.stalledThresholdDays,
1341
- upstreamTasks
1342
- });
1343
- }
1344
-
1345
- // --- Time Tracking ---
1346
- async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string; loggedAt?: string }): Promise<TimeEntry> {
1347
- if (!Number.isFinite(entry.hours) || entry.hours <= 0) {
1348
- throw new ValidationError('Logged hours must be a positive number.');
1349
- }
1350
-
1351
- const task = await this.getTask(entry.taskId);
1352
- if (this.enforcing) {
1353
- if (!task) throw new NotFoundError(`Task "${entry.taskId}" not found.`);
1354
- await this.requireProjectAccess('time.log', task.projectId);
1355
- }
1356
- if (task) {
1357
- const newLoggedHours = (task.loggedHours || 0) + entry.hours;
1358
- const newActualHours = (task.actualHours || 0) + entry.hours;
1359
- const newBillableHours = entry.isBillable !== false ? (task.billableHours || 0) + entry.hours : task.billableHours;
1360
- await this.store.updateTask(task.id, {
1361
- loggedHours: newLoggedHours,
1362
- actualHours: newActualHours,
1363
- billableHours: newBillableHours
1364
- });
1365
- }
1366
-
1367
- const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> & { loggedAt?: string } = {
1368
- ...entry,
1369
- userId: this.actor?.userId || entry.userId || task?.assigneeId || 'system',
1370
- loggedAt: entry.loggedAt
1371
- };
1372
-
1373
- const created = await this.store.logTime(fullEntry);
1374
- const now = new Date().toISOString();
1375
-
1376
- const event: TimeLoggedEvent = {
1377
- id: `evt_${crypto.randomUUID()}`,
1378
- name: 'time.logged',
1379
- aggregateId: entry.taskId,
1380
- aggregateType: 'Task',
1381
- occurredAt: now,
1382
- payload: { taskId: entry.taskId, timeEntry: created }
1383
- };
1384
- await this.events.publish(event);
1385
-
1386
- return created;
1387
- }
1388
-
1389
- // --- Comments ---
1390
- async getComments(taskId: string): Promise<Comment[]> {
1391
- if (this.enforcing && !(await this.getTask(taskId))) return [];
1392
- return this.store.getComments(taskId);
1393
- }
1394
-
1395
- async getComment(id: string): Promise<Comment | null> {
1396
- const comment = await this.store.getComment(id);
1397
- if (!comment) return null;
1398
- return (await this.canReadProject(await this.projectIdOfTask(comment.taskId))) ? comment : null;
1399
- }
1400
-
1401
- async getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]> {
1402
- const activities = await this.store.getActivities(filter);
1403
- return this.filterReadable(activities, (a) => a.projectId ?? this.projectIdOfTask(a.taskId));
1404
- }
1405
-
1406
- async getTimeEntries(taskId: string): Promise<TimeEntry[]> {
1407
- if (this.enforcing && !(await this.getTask(taskId))) return [];
1408
- return this.store.getTimeEntries(taskId);
1409
- }
1410
-
1411
- async addComment(
1412
- input: Omit<Comment, 'id' | 'createdAt' | 'updatedAt' | 'authorId'> & { authorId?: string }
1413
- ): Promise<Comment> {
1414
- if (this.enforcing) {
1415
- const task = await this.getTask(input.taskId);
1416
- if (!task) throw new NotFoundError(`Task "${input.taskId}" not found.`);
1417
- await this.requireProjectAccess('comment.create', task.projectId);
1418
- }
1419
- const comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1420
- ? { ...input, authorId: this.actor.userId, authorType: this.actor.actorType ?? 'user' }
1421
- : { ...input, authorId: input.authorId ?? 'system' };
1422
- const mentions = comment.mentions ?? extractMentions(comment.content);
1423
- const created = await this.store.addComment({ ...comment, mentions });
1424
- const now = new Date().toISOString();
1425
-
1426
- const event: CommentAddedEvent = {
1427
- id: `evt_${crypto.randomUUID()}`,
1428
- name: 'comment.created',
1429
- aggregateId: created.id,
1430
- aggregateType: 'Comment',
1431
- occurredAt: now,
1432
- payload: { taskId: comment.taskId, comment: created }
1433
- };
1434
- await this.events.publish(event);
1435
-
1436
- return created;
1437
- }
1438
-
1439
- async updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null> {
1440
- const existing = await this.getComment(id);
1441
- if (!existing) return null;
1442
- await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1443
- type: 'comment',
1444
- ownerId: existing.authorId
1445
- });
1446
-
1447
- const mentions = updates.mentions ?? (updates.content ? extractMentions(updates.content) : undefined);
1448
- const toUpdate = mentions !== undefined ? { ...updates, mentions } : updates;
1449
-
1450
- const updated = await this.store.updateComment(id, toUpdate);
1451
- if (!updated) return null;
1452
-
1453
- const now = new Date().toISOString();
1454
- const event: CommentUpdatedEvent = {
1455
- id: `evt_${crypto.randomUUID()}`,
1456
- name: 'comment.updated',
1457
- aggregateId: updated.id,
1458
- aggregateType: 'Comment',
1459
- occurredAt: now,
1460
- payload: { comment: updated, previous: existing }
1461
- };
1462
- await this.events.publish(event);
1463
-
1464
- return updated;
1465
- }
1466
-
1467
- async deleteComment(id: string): Promise<boolean> {
1468
- const existing = await this.getComment(id);
1469
- if (!existing) return false;
1470
- await this.requireProjectAccess('comment.moderate', await this.projectIdOfTask(existing.taskId), {
1471
- type: 'comment',
1472
- ownerId: existing.authorId
1473
- });
1474
-
1475
- const deleted = await this.store.deleteComment(id);
1476
- if (deleted) {
1477
- const now = new Date().toISOString();
1478
- const event: CommentDeletedEvent = {
1479
- id: `evt_${crypto.randomUUID()}`,
1480
- name: 'comment.deleted',
1481
- aggregateId: id,
1482
- aggregateType: 'Comment',
1483
- occurredAt: now,
1484
- payload: { taskId: existing.taskId, commentId: id }
1485
- };
1486
- await this.events.publish(event);
1487
-
1488
- }
1489
- return deleted;
1490
- }
1491
-
1492
- async addCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1493
- const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1494
- const existing = await this.getComment(commentId);
1495
- if (!existing) return null;
1496
- await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1497
-
1498
- let updated: Comment | null;
1499
- if (this.store.addReaction) {
1500
- updated = await this.store.addReaction(commentId, reaction);
1501
- } else {
1502
- const reactions = existing.reactions ? [...existing.reactions] : [];
1503
- const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
1504
- if (!alreadyExists) {
1505
- reactions.push({
1506
- emoji: reaction.emoji,
1507
- userId: reaction.userId,
1508
- createdAt: new Date().toISOString()
1509
- });
1510
- }
1511
- updated = await this.store.updateComment(commentId, { reactions });
1512
- }
1513
-
1514
- if (!updated) return null;
1515
-
1516
- const now = new Date().toISOString();
1517
- const event: CommentReactionAddedEvent = {
1518
- id: `evt_${crypto.randomUUID()}`,
1519
- name: 'comment.reaction.added',
1520
- aggregateId: commentId,
1521
- aggregateType: 'Comment',
1522
- occurredAt: now,
1523
- payload: {
1524
- commentId,
1525
- reaction: {
1526
- emoji: reaction.emoji,
1527
- userId: reaction.userId,
1528
- createdAt: now
1529
- },
1530
- comment: updated
1531
- }
1532
- };
1533
- await this.events.publish(event);
1534
-
1535
- return updated;
1536
- }
1537
-
1538
- async removeCommentReaction(commentId: string, input: { emoji: string; userId?: string }): Promise<Comment | null> {
1539
- const reaction = { emoji: input.emoji, userId: this.actor?.userId ?? input.userId ?? 'system' };
1540
- const existing = await this.getComment(commentId);
1541
- if (!existing) return null;
1542
- await this.requireProjectAccess('comment.create', await this.projectIdOfTask(existing.taskId));
1543
-
1544
- let updated: Comment | null;
1545
- if (this.store.removeReaction) {
1546
- updated = await this.store.removeReaction(commentId, reaction);
1547
- } else {
1548
- const reactions = (existing.reactions || []).filter(
1549
- (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
1550
- );
1551
- updated = await this.store.updateComment(commentId, { reactions });
1552
- }
1553
-
1554
- if (!updated) return null;
1555
-
1556
- const now = new Date().toISOString();
1557
- const event: CommentReactionRemovedEvent = {
1558
- id: `evt_${crypto.randomUUID()}`,
1559
- name: 'comment.reaction.removed',
1560
- aggregateId: commentId,
1561
- aggregateType: 'Comment',
1562
- occurredAt: now,
1563
- payload: { commentId, reaction, comment: updated }
1564
- };
1565
- await this.events.publish(event);
1566
-
1567
- return updated;
1568
- }
1569
-
1570
- // --- Attachments ---
1571
- async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string; artifactType?: string }): Promise<Attachment[]> {
1572
- const attachments = await this.filterReadable(await this.store.getAttachments(filter), (a) =>
1573
- this.projectIdOfAttachment(a)
1574
- );
1575
- if (filter?.artifactType) {
1576
- return attachments.filter(a => a.artifactType === filter.artifactType);
1577
- }
1578
- return attachments;
1579
- }
1580
-
1581
- async getAttachment(id: string): Promise<Attachment | null> {
1582
- const attachment = await this.store.getAttachment(id);
1583
- if (!attachment) return null;
1584
- return (await this.canReadProject(await this.projectIdOfAttachment(attachment))) ? attachment : null;
1585
- }
1586
-
1587
- private async requireAttachmentCreate(link: { projectId?: string; taskId?: string; commentId?: string }): Promise<void> {
1588
- if (!this.enforcing) return;
1589
- const projectId = await this.projectIdOfAttachment(link);
1590
- if (!projectId) throw new ValidationError('Attachments must reference a project, task or comment.');
1591
- await this.requireProjectAccess('attachment.create', projectId);
1592
- }
1593
-
1594
- async createAttachment(
1595
- input: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt' | 'uploaderId'> & { uploaderId?: string }
1596
- ): Promise<Attachment> {
1597
- await this.requireAttachmentCreate(input);
1598
- this.assertUploadAllowed(input.mimeType, input.sizeBytes);
1599
- if (this.actor) await this.assertStorageKeyBelongs(input);
1600
- const attachment: Omit<Attachment, 'id' | 'createdAt' | 'updatedAt'> = this.actor
1601
- ? { ...input, uploaderId: this.actor.userId, uploaderType: this.actor.actorType ?? 'user' }
1602
- : { ...input, uploaderId: input.uploaderId ?? 'system' };
1603
- validateAttachmentUrl(attachment.url);
1604
- const created = await this.store.createAttachment(attachment);
1605
- const now = new Date().toISOString();
1606
-
1607
- const event: AttachmentCreatedEvent = {
1608
- id: `evt_${crypto.randomUUID()}`,
1609
- name: 'attachment.created',
1610
- aggregateId: created.id,
1611
- aggregateType: 'Attachment',
1612
- occurredAt: now,
1613
- payload: { attachment: created }
1614
- };
1615
- await this.events.publish(event);
1616
-
1617
- return created;
1618
- }
1619
-
1620
- async deleteAttachment(id: string): Promise<boolean> {
1621
- const existing = await this.getAttachment(id);
1622
- if (!existing) return false;
1623
- await this.requireProjectAccess('attachment.delete', await this.projectIdOfAttachment(existing), {
1624
- type: 'attachment',
1625
- ownerId: existing.uploaderId
1626
- });
1627
-
1628
- if (existing.storageKey && this.fileStorage) {
1629
- try {
1630
- await this.fileStorage.delete(existing.storageKey);
1631
- } catch {
1632
- // Silently continue if underlying file is already gone
1633
- }
1634
- }
1635
-
1636
- const deleted = await this.store.deleteAttachment(id);
1637
- if (deleted) {
1638
- const now = new Date().toISOString();
1639
- const event: AttachmentDeletedEvent = {
1640
- id: `evt_${crypto.randomUUID()}`,
1641
- name: 'attachment.deleted',
1642
- aggregateId: id,
1643
- aggregateType: 'Attachment',
1644
- occurredAt: now,
1645
- payload: {
1646
- attachmentId: id,
1647
- storageKey: existing.storageKey,
1648
- url: existing.url,
1649
- projectId: await this.projectIdOfAttachment(existing)
1650
- }
1651
- };
1652
- await this.events.publish(event);
1653
-
1654
- }
1655
- return deleted;
1656
- }
1657
-
1658
- async uploadAttachmentFile(
1659
- input: UploadFileInput & {
1660
- taskId?: string;
1661
- projectId?: string;
1662
- commentId?: string;
1663
- uploaderId?: string;
1664
- uploaderType?: 'user' | 'agent' | 'system';
1665
- artifactType?: 'plan' | 'spec' | 'deliverable' | 'review' | 'general';
1666
- metadata?: Record<string, unknown>;
1667
- }
1668
- ): Promise<Attachment> {
1669
- if (!this.fileStorage) {
1670
- throw new Error('No FileStorageAdapter configured in CriticalPathEngine. Pass "fileStorage" in config to enable direct file uploads.');
1671
- }
1672
- // Check before uploading so denied requests do not leave orphaned files behind.
1673
- await this.requireAttachmentCreate(input);
1674
- // Files always live under their project's prefix; only trusted base-engine calls may choose another.
1675
- const projectId = await this.projectIdOfAttachment(input);
1676
- if (this.actor && !projectId) {
1677
- throw new ValidationError('Attachments must reference a project, task or comment.');
1678
- }
1679
-
1680
- // Decode once so limits apply to the real byte size, then hand the bytes to the adapter.
1681
- const data = normalizeUploadData(input.data, input.encoding, input.mimeType);
1682
- this.assertUploadAllowed(input.mimeType || 'application/octet-stream', data.byteLength);
1683
-
1684
- const uploadResult = await this.fileStorage.upload({
1685
- filename: input.filename,
1686
- data,
1687
- mimeType: input.mimeType,
1688
- pathPrefix: projectId && (this.actor || !input.pathPrefix) ? `projects/${projectId}` : input.pathPrefix
1689
- });
1690
-
1691
- return this.createAttachment({
1692
- taskId: input.taskId,
1693
- projectId: input.projectId,
1694
- commentId: input.commentId,
1695
- uploaderId: input.uploaderId,
1696
- uploaderType: input.uploaderType || 'user',
1697
- filename: input.filename,
1698
- mimeType: uploadResult.mimeType,
1699
- sizeBytes: uploadResult.sizeBytes,
1700
- url: uploadResult.url,
1701
- storageKey: uploadResult.storageKey,
1702
- artifactType: input.artifactType,
1703
- metadata: input.metadata
1704
- });
1705
- }
1706
-
1707
- async readAttachmentText(id: string): Promise<string> {
1708
- const attachment = await this.getAttachment(id);
1709
- if (!attachment) {
1710
- throw new NotFoundError(`Attachment with id "${id}" not found.`);
1711
- }
1712
-
1713
- if (attachment.storageKey && this.fileStorage && typeof this.fileStorage.download === 'function') {
1714
- const bytes = await this.fileStorage.download(attachment.storageKey);
1715
- return new TextDecoder().decode(bytes);
1716
- }
1717
-
1718
- if (attachment.url) {
1719
- if (attachment.url.startsWith('data:')) {
1720
- const commaIndex = attachment.url.indexOf(',');
1721
- const encoded = commaIndex !== -1 ? attachment.url.substring(commaIndex + 1) : attachment.url;
1722
- if (typeof Buffer !== 'undefined') {
1723
- return Buffer.from(encoded, 'base64').toString('utf-8');
1724
- }
1725
- if (typeof atob === 'function') {
1726
- return atob(encoded);
1727
- }
1728
- }
1729
-
1730
- const res = await fetch(attachment.url);
1731
- if (!res.ok) {
1732
- throw new Error(`Failed to fetch attachment from URL "${attachment.url}": ${res.statusText}`);
1733
- }
1734
- return await res.text();
1735
- }
1736
-
1737
- throw new Error(`Cannot read attachment "${id}": no valid storageKey or URL found.`);
1738
- }
1739
-
1740
- /**
1741
- * Returns a URL the client can upload a file to directly (e.g. a signed S3 PUT). The engine
1742
- * chooses the storage key under `projects/<projectId>/`; register the uploaded file afterwards
1743
- * with `createAttachment({ projectId, storageKey, ... })` using the returned `storageKey`.
1744
- */
1745
- async getPresignedAttachmentUploadUrl(options: {
1746
- projectId: string;
1747
- filename: string;
1748
- contentType?: string;
1749
- expiresInSeconds?: number;
1750
- }): Promise<PresignedUploadResult> {
1751
- if (!this.fileStorage || !this.fileStorage.getPresignedUploadUrl) {
1752
- throw new Error('Presigned uploads are not supported by the configured FileStorageAdapter.');
1753
- }
1754
- if (!options.projectId) throw new ValidationError('projectId is required for presigned uploads.');
1755
- this.assertUploadAllowed(options.contentType ?? 'application/octet-stream');
1756
- if (this.enforcing) {
1757
- await this.requireProjectAccess('attachment.create', options.projectId);
1758
- } else if (!(await this.store.getProject(options.projectId))) {
1759
- throw new NotFoundError(`Project "${options.projectId}" not found.`);
1760
- }
1761
- return this.fileStorage.getPresignedUploadUrl({
1762
- storageKey: buildStorageKey(`projects/${options.projectId}`, options.filename),
1763
- contentType: options.contentType,
1764
- expiresInSeconds: options.expiresInSeconds
1765
- });
1766
- }
1767
-
1768
- /** Enforces `config.uploads` (MIME allow-list with `type/*` wildcards, and maximum size). */
1769
- private assertUploadAllowed(mimeType: string, sizeBytes?: number): void {
1770
- const limits = this.config.uploads;
1771
- if (!limits) return;
1772
- if (limits.maxBytes !== undefined && sizeBytes !== undefined && sizeBytes > limits.maxBytes) {
1773
- throw new ValidationError(`File is ${sizeBytes} bytes; the maximum is ${limits.maxBytes} bytes.`);
1774
- }
1775
- if (limits.allowedMimeTypes) {
1776
- const type = mimeType.split(';')[0].trim().toLowerCase();
1777
- const allowed = limits.allowedMimeTypes.some((pattern) => {
1778
- const p = pattern.toLowerCase();
1779
- return p.endsWith('/*') ? type.startsWith(p.slice(0, -1)) : type === p;
1780
- });
1781
- if (!allowed) throw new ValidationError(`File type "${type}" is not allowed.`);
1782
- }
1783
- }
1784
-
1785
- /**
1786
- * A caller-supplied `storageKey` must live under the attachment's project prefix, because
1787
- * deleting the attachment deletes that file: otherwise anyone could register, then delete,
1788
- * another project's files.
1789
- */
1790
- private async assertStorageKeyBelongs(link: { projectId?: string; taskId?: string; commentId?: string; storageKey?: string }): Promise<void> {
1791
- if (!link.storageKey) return;
1792
- const projectId = await this.projectIdOfAttachment(link);
1793
- const prefix = `projects/${projectId}/`;
1794
- const segments = link.storageKey.split('/');
1795
- if (!projectId || !link.storageKey.startsWith(prefix) || segments.some((s) => s === '..' || s === '.')) {
1796
- throw new ValidationError(`storageKey must be under "${projectId ? prefix : 'projects/<projectId>/'}" for this attachment.`);
1797
- }
1798
- }
1799
-
1800
- // --- Teams ---
1801
- async getTeams(): Promise<Team[]> {
1802
- const teams = await this.store.getTeams();
1803
- return teams.filter((t) => this.inActorTenant(t));
1804
- }
1805
-
1806
- async getTeam(id: string): Promise<Team | null> {
1807
- const team = await this.store.getTeam(id);
1808
- return team && this.inActorTenant(team) ? team : null;
1809
- }
1810
-
1811
- async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
1812
- await this.requireWorkspaceAccess('workspace.manage');
1813
- const created = await this.store.createTeam({ ...team, tenantId: this.actor ? this.actor.tenantId : team.tenantId });
1814
- const now = new Date().toISOString();
1815
-
1816
- const event: TeamCreatedEvent = {
1817
- id: `evt_${crypto.randomUUID()}`,
1818
- name: 'team.created',
1819
- aggregateId: created.id,
1820
- aggregateType: 'Team',
1821
- occurredAt: now,
1822
- payload: { team: created }
1823
- };
1824
- await this.events.publish(event);
1825
-
1826
- return created;
1827
- }
1828
-
1829
- async updateTeam(id: string, updates: Partial<Team>): Promise<Team | null> {
1830
- const existing = await this.getTeam(id);
1831
- if (!existing) return null;
1832
- await this.requireWorkspaceAccess('workspace.manage');
1833
- const updated = await this.store.updateTeam(id, this.withoutTenant(updates));
1834
- if (updated) {
1835
- await this.publishEvent<TeamUpdatedEvent>({
1836
- name: 'team.updated',
1837
- aggregateId: id,
1838
- aggregateType: 'Team',
1839
- payload: { team: updated, previous: existing }
1840
- });
1841
- }
1842
- return updated;
1843
- }
1844
-
1845
- async deleteTeam(id: string): Promise<boolean> {
1846
- const existing = await this.getTeam(id);
1847
- if (!existing) return false;
1848
- await this.requireWorkspaceAccess('workspace.manage');
1849
- const deleted = await this.store.deleteTeam(id);
1850
- if (deleted) {
1851
- await this.publishEvent<TeamDeletedEvent>({
1852
- name: 'team.deleted',
1853
- aggregateId: id,
1854
- aggregateType: 'Team',
1855
- payload: { teamId: id, name: existing.name, tenantId: existing.tenantId }
1856
- });
1857
- }
1858
- return deleted;
1859
- }
1860
-
1861
- // --- Containers ---
1862
- async getContainers(projectId: string): Promise<TaskContainer[]> {
1863
- if (!(await this.canReadProject(projectId))) return [];
1864
- return this.store.getContainers(projectId);
1865
- }
1866
-
1867
- async getContainer(id: string): Promise<TaskContainer | null> {
1868
- const container = await this.store.getContainer(id);
1869
- if (!container) return null;
1870
- return (await this.canReadProject(container.projectId)) ? container : null;
1871
- }
1872
-
1873
- async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
1874
- await this.requireProjectAccess('plan.manage', container.projectId);
1875
- const created = await this.store.createContainer(container);
1876
- const now = new Date().toISOString();
1877
-
1878
- const event: ContainerCreatedEvent = {
1879
- id: `evt_${crypto.randomUUID()}`,
1880
- name: 'container.created',
1881
- aggregateId: created.id,
1882
- aggregateType: 'Container',
1883
- occurredAt: now,
1884
- payload: { container: created }
1885
- };
1886
- await this.events.publish(event);
1887
-
1888
- return created;
1889
- }
1890
-
1891
- async updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null> {
1892
- const existing = await this.getContainer(id);
1893
- if (!existing) return null;
1894
- await this.requireProjectAccess('plan.manage', existing.projectId);
1895
- const updated = await this.store.updateContainer(id, updates);
1896
- if (updated) {
1897
- await this.publishEvent<ContainerUpdatedEvent>({
1898
- name: 'container.updated',
1899
- aggregateId: id,
1900
- aggregateType: 'Container',
1901
- payload: { container: updated, previous: existing }
1902
- });
1903
- }
1904
- return updated;
1905
- }
1906
-
1907
- /** Deletes a container. Its tasks and nested containers are kept but no longer reference it. */
1908
- async deleteContainer(id: string): Promise<boolean> {
1909
- const existing = await this.getContainer(id);
1910
- if (!existing) return false;
1911
- await this.requireProjectAccess('plan.manage', existing.projectId);
1912
- await this.clearTaskReferences(existing.projectId, 'containerId', id);
1913
- for (const child of (await this.store.getContainers(existing.projectId)).filter((c) => c.parentId === id)) {
1914
- await this.store.updateContainer(child.id, { parentId: undefined });
1915
- }
1916
- const deleted = await this.store.deleteContainer(id);
1917
- if (deleted) {
1918
- await this.publishEvent<ContainerDeletedEvent>({
1919
- name: 'container.deleted',
1920
- aggregateId: id,
1921
- aggregateType: 'Container',
1922
- payload: { containerId: id, projectId: existing.projectId, name: existing.name }
1923
- });
1924
- }
1925
- return deleted;
1926
- }
1927
-
1928
- /** Unsets a reference (e.g. `iterationId`) on every task in the project that points at `id`. */
1929
- private async clearTaskReferences(
1930
- projectId: string,
1931
- field: 'containerId' | 'iterationId' | 'deliverableId',
1932
- id: string
1933
- ): Promise<void> {
1934
- for (const task of await this.store.getTasks(projectId)) {
1935
- if (task[field] === id) await this.store.updateTask(task.id, { [field]: undefined });
1936
- }
1937
- }
1938
-
1939
- // --- Deliverables ---
1940
- async getDeliverables(projectId: string): Promise<Deliverable[]> {
1941
- if (!(await this.canReadProject(projectId))) return [];
1942
- return this.store.getDeliverables(projectId);
1943
- }
1944
-
1945
- async getDeliverable(id: string): Promise<Deliverable | null> {
1946
- const deliverable = await this.store.getDeliverable(id);
1947
- if (!deliverable) return null;
1948
- return (await this.canReadProject(deliverable.projectId)) ? deliverable : null;
1949
- }
1950
-
1951
- async createDeliverable(input: CreateDeliverableInput): Promise<Deliverable> {
1952
- await this.requireProjectAccess('plan.manage', input.projectId);
1953
- const project = await this.store.getProject(input.projectId);
1954
- if (project?.customFieldDefinitions && input.customFields) {
1955
- validateCustomFieldValues(project.customFieldDefinitions, input.customFields, this.plugins.getCustomFieldTypes());
1956
- }
1957
-
1958
- const created = await this.store.createDeliverable({
1959
- ...input,
1960
- status: input.status || 'planned',
1961
- outputUrls: input.outputUrls ?? [],
1962
- customFields: input.customFields ?? {}
1963
- });
1964
- const now = new Date().toISOString();
1965
-
1966
- const event: DeliverableCreatedEvent = {
1967
- id: `evt_${crypto.randomUUID()}`,
1968
- name: 'deliverable.created',
1969
- aggregateId: created.id,
1970
- aggregateType: 'Deliverable',
1971
- occurredAt: now,
1972
- payload: { deliverable: created }
1973
- };
1974
- await this.events.publish(event);
1975
-
1976
- await this.store.logActivity({
1977
- projectId: created.projectId,
1978
- actorId: this.actorIdOr('system'),
1979
- action: 'deliverable.created',
1980
- details: { title: created.title, format: created.format }
1981
- });
1982
- return created;
1983
- }
1984
-
1985
- async updateDeliverable(id: string, updates: Partial<Deliverable>): Promise<Deliverable | null> {
1986
- const existing = await this.getDeliverable(id);
1987
- if (!existing) return null;
1988
- await this.requireProjectAccess('plan.manage', existing.projectId);
1989
-
1990
- if (updates.status && updates.status === 'delivered' && !existing.deliveredAt && !updates.deliveredAt) {
1991
- updates.deliveredAt = new Date().toISOString();
1992
- }
1993
-
1994
- const updated = await this.store.updateDeliverable(id, updates);
1995
- if (updated) {
1996
- const now = new Date().toISOString();
1997
-
1998
- if (updates.status && updates.status !== existing.status) {
1999
- const statusEvent: DeliverableStatusChangedEvent = {
2000
- id: `evt_${crypto.randomUUID()}`,
2001
- name: 'deliverable.status_changed',
2002
- aggregateId: updated.id,
2003
- aggregateType: 'Deliverable',
2004
- occurredAt: now,
2005
- payload: {
2006
- deliverable: updated,
2007
- previousStatus: existing.status,
2008
- newStatus: updated.status
2009
- }
2010
- };
2011
- await this.events.publish(statusEvent);
2012
- }
2013
-
2014
- const updateEvent: DeliverableUpdatedEvent = {
2015
- id: `evt_${crypto.randomUUID()}`,
2016
- name: 'deliverable.updated',
2017
- aggregateId: updated.id,
2018
- aggregateType: 'Deliverable',
2019
- occurredAt: now,
2020
- payload: { deliverable: updated, previous: existing }
2021
- };
2022
- await this.events.publish(updateEvent);
2023
-
2024
- await this.store.logActivity({
2025
- projectId: updated.projectId,
2026
- actorId: this.actorIdOr('system'),
2027
- action: 'deliverable.updated',
2028
- details: { title: updated.title, status: updated.status }
2029
- });
2030
- }
2031
- return updated;
2032
- }
2033
-
2034
- async deleteDeliverable(id: string): Promise<boolean> {
2035
- const existing = await this.getDeliverable(id);
2036
- if (!existing) return false;
2037
- await this.requireProjectAccess('plan.manage', existing.projectId);
2038
- await this.clearTaskReferences(existing.projectId, 'deliverableId', id);
2039
-
2040
- const deleted = await this.store.deleteDeliverable(id);
2041
- if (deleted) {
2042
- const now = new Date().toISOString();
2043
- const event: DeliverableDeletedEvent = {
2044
- id: `evt_${crypto.randomUUID()}`,
2045
- name: 'deliverable.deleted',
2046
- aggregateId: id,
2047
- aggregateType: 'Deliverable',
2048
- occurredAt: now,
2049
- payload: { deliverableId: id, projectId: existing.projectId }
2050
- };
2051
- await this.events.publish(event);
2052
-
2053
- await this.store.logActivity({
2054
- projectId: existing.projectId,
2055
- actorId: this.actorIdOr('system'),
2056
- action: 'deliverable.deleted',
2057
- details: { title: existing.title }
2058
- });
2059
- }
2060
- return deleted;
2061
- }
2062
-
2063
- async getDeliverableSummary(deliverableId: string): Promise<DeliverableSummary | null> {
2064
- const deliverable = await this.getDeliverable(deliverableId);
2065
- if (!deliverable) return null;
2066
-
2067
- const project = await this.store.getProject(deliverable.projectId);
2068
- const workflow = await this.resolveProjectWorkflow(deliverable.projectId);
2069
- const allTasks = await this.store.getTasks(deliverable.projectId);
2070
- const tasks = allTasks.filter((t) => t.deliverableId === deliverableId);
2071
-
2072
- const totalTasks = tasks.length;
2073
- let completedTasks = 0;
2074
- let activeTasks = 0;
2075
- let estimatedHours = 0;
2076
- let loggedHours = 0;
2077
- let totalProgress = 0;
2078
-
2079
- for (const task of tasks) {
2080
- const statusDef = resolveStatusDefinition(task.status, project?.statusDefinitions || workflow?.statuses);
2081
- if (statusDef.category === 'completed') {
2082
- completedTasks++;
2083
- } else if (statusDef.category === 'in_progress') {
2084
- activeTasks++;
2085
- }
2086
-
2087
- if (task.estimatedHours) estimatedHours += task.estimatedHours;
2088
- if (task.loggedHours) loggedHours += task.loggedHours;
2089
-
2090
- if (typeof task.progress === 'number') {
2091
- totalProgress += task.progress;
2092
- } else {
2093
- totalProgress += statusDef.category === 'completed' ? 100 : 0;
2094
- }
2095
- }
2096
-
2097
- const progressPercentage = totalTasks > 0 ? Math.round(totalProgress / totalTasks) : 0;
2098
-
2099
- return {
2100
- deliverable,
2101
- totalTasks,
2102
- completedTasks,
2103
- activeTasks,
2104
- progressPercentage,
2105
- estimatedHours,
2106
- loggedHours
2107
- };
2108
- }
2109
-
2110
- // --- Iterations ---
2111
- async getIterations(projectId: string): Promise<Iteration[]> {
2112
- if (!(await this.canReadProject(projectId))) return [];
2113
- return this.store.getIterations(projectId);
2114
- }
2115
-
2116
- async getIteration(id: string): Promise<Iteration | null> {
2117
- const iteration = await this.store.getIteration(id);
2118
- if (!iteration) return null;
2119
- return (await this.canReadProject(iteration.projectId)) ? iteration : null;
2120
- }
2121
-
2122
- async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
2123
- await this.requireProjectAccess('plan.manage', iteration.projectId);
2124
- const created = await this.store.createIteration(iteration);
2125
- await this.publishEvent<IterationCreatedEvent>({
2126
- name: 'iteration.created',
2127
- aggregateId: created.id,
2128
- aggregateType: 'Iteration',
2129
- payload: { iteration: created }
2130
- });
2131
- if (created.status === 'active') {
2132
- const now = new Date().toISOString();
2133
- const event: IterationStartedEvent = {
2134
- id: `evt_${crypto.randomUUID()}`,
2135
- name: 'iteration.started',
2136
- aggregateId: created.id,
2137
- aggregateType: 'Iteration',
2138
- occurredAt: now,
2139
- payload: { iteration: created }
2140
- };
2141
- await this.events.publish(event);
2142
- }
2143
- return created;
2144
- }
2145
-
2146
- async updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null> {
2147
- const existing = await this.getIteration(id);
2148
- if (!existing) return null;
2149
- await this.requireProjectAccess('plan.manage', existing.projectId);
2150
-
2151
- const updated = await this.store.updateIteration(id, updates);
2152
- if (updated) {
2153
- await this.publishEvent<IterationUpdatedEvent>({
2154
- name: 'iteration.updated',
2155
- aggregateId: id,
2156
- aggregateType: 'Iteration',
2157
- payload: { iteration: updated, previous: existing }
2158
- });
2159
- const now = new Date().toISOString();
2160
- if (existing.status !== 'active' && updated.status === 'active') {
2161
- const event: IterationStartedEvent = {
2162
- id: `evt_${crypto.randomUUID()}`,
2163
- name: 'iteration.started',
2164
- aggregateId: updated.id,
2165
- aggregateType: 'Iteration',
2166
- occurredAt: now,
2167
- payload: { iteration: updated }
2168
- };
2169
- await this.events.publish(event);
2170
- } else if (existing.status !== 'completed' && updated.status === 'completed') {
2171
- const event: IterationCompletedEvent = {
2172
- id: `evt_${crypto.randomUUID()}`,
2173
- name: 'iteration.completed',
2174
- aggregateId: updated.id,
2175
- aggregateType: 'Iteration',
2176
- occurredAt: now,
2177
- payload: { iteration: updated }
2178
- };
2179
- await this.events.publish(event);
2180
- }
2181
- }
2182
- return updated;
2183
- }
2184
-
2185
- /** Deletes an iteration. Its tasks are kept and moved back to the backlog (no iteration). */
2186
- async deleteIteration(id: string): Promise<boolean> {
2187
- const existing = await this.getIteration(id);
2188
- if (!existing) return false;
2189
- await this.requireProjectAccess('plan.manage', existing.projectId);
2190
- await this.clearTaskReferences(existing.projectId, 'iterationId', id);
2191
- const deleted = await this.store.deleteIteration(id);
2192
- if (deleted) {
2193
- await this.publishEvent<IterationDeletedEvent>({
2194
- name: 'iteration.deleted',
2195
- aggregateId: id,
2196
- aggregateType: 'Iteration',
2197
- payload: { iterationId: id, projectId: existing.projectId, name: existing.name }
2198
- });
2199
- }
2200
- return deleted;
2201
- }
2202
-
2203
- // --- Ladder of Abstraction & Critical Path Method ---
2204
- async calculateCriticalPath(
2205
- projectId: string,
2206
- options: CPMOptions = {}
2207
- ): Promise<CriticalPathAnalysis> {
2208
- await this.requireProjectAccess('project.read', projectId);
2209
- const project = await this.store.getProject(projectId);
2210
- const tasks = await this.store.getTasks(projectId);
2211
- const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
2212
- const seenDepIds = new Set<string>();
2213
- const dependencies: TaskDependency[] = [];
2214
- for (const deps of allDepArrays) {
2215
- for (const d of deps) {
2216
- if (!seenDepIds.has(d.id)) {
2217
- seenDepIds.add(d.id);
2218
- dependencies.push(d);
2219
- }
2220
- }
2221
- }
2222
- const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
2223
- const projectStartDate = options.projectStartDate || project?.startDate;
2224
- return calculateCPM(projectId, tasks, dependencies, { schedule, projectStartDate });
2225
- }
2226
-
2227
- async getTimelineLadder(
2228
- projectId: string,
2229
- options: TimelineLadderOptions = {}
2230
- ): Promise<TimelineLadder> {
2231
- await this.requireProjectAccess('project.read', projectId);
2232
- const project = await this.store.getProject(projectId);
2233
- if (!project) {
2234
- throw new NotFoundError(`Project with ID "${projectId}" not found.`);
2235
- }
2236
-
2237
- let tasks = await this.store.getTasks(projectId);
2238
- if (options.containerId) {
2239
- tasks = tasks.filter((t) => t.containerId === options.containerId);
2240
- }
2241
- if (options.iterationId) {
2242
- tasks = tasks.filter((t) => t.iterationId === options.iterationId);
2243
- }
2244
-
2245
- const [containers, iterations, deliverables, attachments, activities] = await Promise.all([
2246
- this.store.getContainers(projectId),
2247
- this.store.getIterations(projectId),
2248
- this.store.getDeliverables(projectId),
2249
- this.store.getAttachments({ projectId }),
2250
- this.store.getActivities({ projectId })
2251
- ]);
2252
-
2253
- const allDepArrays = await Promise.all(tasks.map((t) => this.store.getDependencies(t.id)));
2254
- const seenDepIds = new Set<string>();
2255
- const dependencies: TaskDependency[] = [];
2256
- for (const deps of allDepArrays) {
2257
- for (const d of deps) {
2258
- if (!seenDepIds.has(d.id)) {
2259
- seenDepIds.add(d.id);
2260
- dependencies.push(d);
2261
- }
2262
- }
2263
- }
2264
-
2265
- const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
2266
- const timeEntries: TimeEntry[] = timeEntriesNested.flat();
2267
-
2268
- const schedule = project.schedule || this.config.defaultSchedule;
2269
-
2270
- return buildTimelineLadder(
2271
- {
2272
- project,
2273
- tasks,
2274
- dependencies,
2275
- containers,
2276
- iterations,
2277
- deliverables,
2278
- attachments,
2279
- timeEntries,
2280
- activities,
2281
- schedule
2282
- },
2283
- options
2284
- );
2285
- }
2286
-
2287
- async getTaskLadder(taskId: string): Promise<TaskLadderView | null> {
2288
- const task = await this.getTask(taskId);
2289
- if (!task) return null;
2290
-
2291
- const project = await this.store.getProject(task.projectId);
2292
- if (!project) return null;
2293
-
2294
- const ladder = await this.getTimelineLadder(task.projectId, { level: 'all' });
2295
- const standard = ladder.standard?.tasks.find((t) => t.id === taskId);
2296
- if (!standard) return null;
2297
-
2298
- const concrete = ladder.concrete?.[taskId] || aggregateConcreteEvidenceForTask(task);
2299
- const macroPhase = ladder.macro?.phases.find((p) => p.taskIds.includes(taskId));
2300
- const metrics = await this.getTaskMetrics(taskId);
2301
-
2302
- return {
2303
- taskId,
2304
- macroPhase,
2305
- standard,
2306
- concrete,
2307
- metrics: metrics || undefined
2308
- };
2309
- }
2310
-
2311
- async getTaskMetrics(taskId: string, options?: MetricOptions): Promise<TaskMetrics | null> {
2312
- const task = await this.getTask(taskId);
2313
- if (!task) return null;
2314
-
2315
- const [activities, timeEntries, project, workflow] = await Promise.all([
2316
- this.store.getActivities({ taskId }),
2317
- this.store.getTimeEntries(taskId),
2318
- this.store.getProject(task.projectId),
2319
- this.resolveProjectWorkflow(task.projectId)
2320
- ]);
2321
-
2322
- const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
2323
-
2324
- return calculateTaskMetrics(task, activities, timeEntries, {
2325
- customStatusDefinitions,
2326
- ...options
2327
- });
2328
- }
2329
-
2330
- async getTaskProgressHistory(taskId: string, options?: MetricOptions): Promise<TaskProgressHistory | null> {
2331
- const task = await this.getTask(taskId);
2332
- if (!task) return null;
2333
-
2334
- const [activities, project, workflow] = await Promise.all([
2335
- this.store.getActivities({ taskId }),
2336
- this.store.getProject(task.projectId),
2337
- this.resolveProjectWorkflow(task.projectId)
2338
- ]);
2339
-
2340
- const customStatusDefinitions = project?.statusDefinitions || workflow?.statuses;
2341
-
2342
- return reconstructTaskProgressHistory(task, activities, {
2343
- customStatusDefinitions,
2344
- ...options
2345
- });
2346
- }
2347
-
2348
- async getWorkloadDistribution(
2349
- projectId?: string,
2350
- options: WorkloadDistributionOptions = {}
2351
- ): Promise<WorkloadDistribution> {
2352
- if (projectId) await this.requireProjectAccess('project.read', projectId);
2353
- const tasks = await this.getTasks(projectId);
2354
- const teams = await this.getTeams();
2355
- const users = await this.getUsers();
2356
- const project = projectId ? await this.store.getProject(projectId) : undefined;
2357
- const schedule = options.schedule || project?.schedule || this.config.defaultSchedule;
2358
-
2359
- const timeEntriesNested = await Promise.all(tasks.map((t) => this.store.getTimeEntries(t.id)));
2360
- const timeEntries: TimeEntry[] = timeEntriesNested.flat();
2361
-
2362
- return calculateWorkloadDistribution(
2363
- {
2364
- tasks,
2365
- timeEntries,
2366
- teams,
2367
- users,
2368
- projectId
2369
- },
2370
- {
2371
- ...options,
2372
- schedule
2373
- }
2374
- );
2375
- }
2376
-
2377
- // --- Users ---
2378
-
2379
- /**
2380
- * The user directory from `config.users` plus `initialData.users`. Users are owned by your app;
2381
- * the engine only reads them for names, capacity and schedules.
2382
- */
2383
- async getUsers(): Promise<User[]> {
2384
- const configured = typeof this.config.users === 'function' ? await this.config.users(this.actor) : this.config.users ?? [];
2385
- const seeded = this.config.initialData?.users ?? [];
2386
- const byId = new Map<string, User>();
2387
- for (const user of [...seeded, ...configured]) byId.set(user.id, user);
2388
- return Array.from(byId.values());
2389
- }
2390
-
2391
- // --- Webhooks ---
2392
-
2393
- /** Webhooks from `config.webhooks` (static, not editable) plus those in the store. */
2394
- private async listAllWebhooks(): Promise<Webhook[]> {
2395
- const fromConfig: Webhook[] = (this.config.webhooks ?? []).map((w, i) => ({
2396
- ...w,
2397
- id: `config_${i}`,
2398
- createdAt: new Date(0).toISOString()
2399
- }));
2400
- return [...fromConfig, ...(await this.store.getWebhooks())];
2401
- }
2402
-
2403
- private toPublicWebhook({ secret, ...webhook }: Webhook): PublicWebhook {
2404
- return { ...webhook, hasSecret: !!secret };
2405
- }
2406
-
2407
- /** Webhooks in the actor's tenant, with secrets redacted. Requires `workspace.manage` on views. */
2408
- async getWebhooks(): Promise<PublicWebhook[]> {
2409
- await this.requireWorkspaceAccess('workspace.manage');
2410
- const webhooks = await this.store.getWebhooks();
2411
- return webhooks.filter((w) => this.inActorTenant(w)).map((w) => this.toPublicWebhook(w));
2412
- }
2413
-
2414
- async getWebhook(id: string): Promise<PublicWebhook | null> {
2415
- await this.requireWorkspaceAccess('workspace.manage');
2416
- const webhook = await this.store.getWebhook(id);
2417
- return webhook && this.inActorTenant(webhook) ? this.toPublicWebhook(webhook) : null;
2418
- }
2419
-
2420
- /**
2421
- * Registers a webhook. The signing secret is returned only here; generate one by omitting
2422
- * `secret`. Deliveries carry `X-CriticalPath-Signature` (see `verifyWebhookSignature`).
2423
- */
2424
- async createWebhook(input: {
2425
- name: string;
2426
- url: string;
2427
- events: Webhook['events'];
2428
- secret?: string;
2429
- active?: boolean;
2430
- }): Promise<{ webhook: PublicWebhook; secret: string }> {
2431
- await this.requireWorkspaceAccess('workspace.manage');
2432
- this.validateWebhookUrl(input.url);
2433
- const secret = input.secret ?? generateWebhookSecret();
2434
- const created = await this.store.addWebhook({
2435
- name: input.name,
2436
- url: input.url,
2437
- events: input.events,
2438
- secret,
2439
- active: input.active ?? true,
2440
- tenantId: this.actor?.tenantId
2441
- });
2442
- this.webhooks.invalidate();
2443
- return { webhook: this.toPublicWebhook(created), secret };
2444
- }
2445
-
2446
- async updateWebhook(
2447
- id: string,
2448
- updates: Partial<Pick<Webhook, 'name' | 'url' | 'events' | 'active' | 'secret'>>
2449
- ): Promise<PublicWebhook | null> {
2450
- if (!(await this.getWebhook(id))) return null;
2451
- if (updates.url) this.validateWebhookUrl(updates.url);
2452
- const { name, url, events, active, secret } = updates;
2453
- const updated = await this.store.updateWebhook(
2454
- id,
2455
- Object.fromEntries(Object.entries({ name, url, events, active, secret }).filter(([, v]) => v !== undefined))
2456
- );
2457
- this.webhooks.invalidate();
2458
- return updated ? this.toPublicWebhook(updated) : null;
2459
- }
2460
-
2461
- async deleteWebhook(id: string): Promise<boolean> {
2462
- if (!(await this.getWebhook(id))) return false;
2463
- const deleted = await this.store.deleteWebhook(id);
2464
- this.webhooks.invalidate();
2465
- return deleted;
2466
- }
2467
-
2468
- private validateWebhookUrl(url: string): void {
2469
- try {
2470
- assertWebhookUrl(url, this.config.webhookDelivery?.allowPrivateUrls);
2471
- } catch (err) {
2472
- throw new ValidationError((err as Error).message);
2473
- }
2474
- }
2475
-
2476
- /** Finds the tenant an event belongs to, from the entity in its payload or its project. */
2477
- private async resolveEventTenant(event: DomainEvent): Promise<string | undefined> {
2478
- const p = event.payload as Record<string, any>;
2479
- const owner = p.project ?? p.workflow ?? p.team;
2480
- if (owner) return owner.tenantId ?? undefined;
2481
- // Deleted projects and workflows carry their tenant in the payload.
2482
- if ('tenantId' in p) return p.tenantId ?? undefined;
2483
-
2484
- const projectId: string | undefined =
2485
- p.projectId ??
2486
- p.task?.projectId ??
2487
- p.deliverable?.projectId ??
2488
- p.iteration?.projectId ??
2489
- p.container?.projectId ??
2490
- p.attachment?.projectId ??
2491
- (await this.projectIdOfTask(p.taskId ?? p.comment?.taskId ?? p.attachment?.taskId ?? p.dependency?.taskId ?? p.timeEntry?.taskId));
2492
- if (!projectId) return undefined;
2493
- return (await this.store.getProject(projectId))?.tenantId ?? undefined;
2494
- }
2495
- }