@critical-path/core 0.5.1 → 0.6.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.
- package/CHANGELOG.md +17 -0
- package/README.md +51 -21
- package/dist/domain/custom-fields.d.ts +9 -0
- package/dist/domain/custom-fields.d.ts.map +1 -0
- package/dist/domain/custom-fields.js +72 -0
- package/dist/domain/custom-fields.js.map +1 -0
- package/dist/domain/ddd.test.d.ts +2 -0
- package/dist/domain/ddd.test.d.ts.map +1 -0
- package/dist/domain/ddd.test.js +246 -0
- package/dist/domain/ddd.test.js.map +1 -0
- package/dist/domain/entities.d.ts +82 -0
- package/dist/domain/entities.d.ts.map +1 -0
- package/dist/domain/entities.js +321 -0
- package/dist/domain/entities.js.map +1 -0
- package/dist/domain/events.d.ts +125 -0
- package/dist/domain/events.d.ts.map +1 -0
- package/dist/domain/events.js +35 -0
- package/dist/domain/events.js.map +1 -0
- package/dist/domain/graph.d.ts +19 -0
- package/dist/domain/graph.d.ts.map +1 -0
- package/dist/domain/graph.js +58 -0
- package/dist/domain/graph.js.map +1 -0
- package/dist/domain/index.d.ts +5 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/domain/index.js +5 -0
- package/dist/domain/index.js.map +1 -0
- package/dist/engine/index.d.ts +10 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +288 -6
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/store/index.d.ts +23 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js.map +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/workflow.js +1 -1
- package/dist/utils/workflow.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/custom-fields.ts +131 -0
- package/src/domain/ddd.test.ts +307 -0
- package/src/domain/entities.ts +390 -0
- package/src/domain/events.ts +171 -0
- package/src/domain/graph.ts +70 -0
- package/src/domain/index.ts +4 -0
- package/src/engine/index.ts +355 -8
- package/src/index.ts +1 -1
- package/src/store/index.ts +35 -11
- package/src/types/index.ts +7 -0
- package/src/utils/workflow.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
package/src/engine/index.ts
CHANGED
|
@@ -2,26 +2,53 @@ import type {
|
|
|
2
2
|
CriticalPathConfig,
|
|
3
3
|
Project,
|
|
4
4
|
Task,
|
|
5
|
+
TaskDependency,
|
|
5
6
|
TaskDependencyGraph,
|
|
6
7
|
Team,
|
|
7
8
|
TaskContainer,
|
|
8
9
|
Iteration,
|
|
10
|
+
Comment,
|
|
11
|
+
TimeEntry,
|
|
9
12
|
WebhookEvent,
|
|
10
|
-
Workflow
|
|
13
|
+
Workflow,
|
|
14
|
+
CreateTaskInput,
|
|
15
|
+
CreateProjectInput
|
|
11
16
|
} from '../types/index.js';
|
|
12
17
|
import { StorageAdapter, InMemoryStore } from '../store/index.js';
|
|
13
18
|
import { PluginRegistry } from '../plugins/index.js';
|
|
14
|
-
import { deriveTaskLifecycleState, type TaskLifecycleState } from '../utils/status.js';
|
|
19
|
+
import { deriveTaskLifecycleState, type TaskLifecycleState, resolveStatusDefinition } from '../utils/status.js';
|
|
15
20
|
import {
|
|
16
21
|
validateTransition,
|
|
17
22
|
getAllowedNextStatuses,
|
|
18
23
|
WorkflowValidationError,
|
|
19
24
|
DEFAULT_SOFTWARE_WORKFLOW
|
|
20
25
|
} from '../utils/workflow.js';
|
|
26
|
+
import {
|
|
27
|
+
DomainEventBus,
|
|
28
|
+
TaskCreatedEvent,
|
|
29
|
+
TaskUpdatedEvent,
|
|
30
|
+
TaskStatusChangedEvent,
|
|
31
|
+
TaskDeletedEvent,
|
|
32
|
+
ProjectCreatedEvent,
|
|
33
|
+
ProjectUpdatedEvent,
|
|
34
|
+
WorkflowCreatedEvent,
|
|
35
|
+
WorkflowUpdatedEvent,
|
|
36
|
+
WorkflowDeletedEvent,
|
|
37
|
+
IterationStartedEvent,
|
|
38
|
+
IterationCompletedEvent,
|
|
39
|
+
TeamCreatedEvent,
|
|
40
|
+
ContainerCreatedEvent,
|
|
41
|
+
TaskDependencyAddedEvent,
|
|
42
|
+
TimeLoggedEvent,
|
|
43
|
+
CommentAddedEvent
|
|
44
|
+
} from '../domain/events.js';
|
|
45
|
+
import { validateCustomFieldValues } from '../domain/custom-fields.js';
|
|
46
|
+
import { detectDependencyCycle, CircularDependencyError } from '../domain/graph.js';
|
|
21
47
|
|
|
22
48
|
export class CriticalPathEngine {
|
|
23
49
|
public readonly store: StorageAdapter;
|
|
24
50
|
public readonly plugins: PluginRegistry;
|
|
51
|
+
public readonly events: DomainEventBus;
|
|
25
52
|
|
|
26
53
|
constructor(config: CriticalPathConfig = {}) {
|
|
27
54
|
this.store = typeof config.store === 'object' && config.store !== null
|
|
@@ -29,6 +56,7 @@ export class CriticalPathEngine {
|
|
|
29
56
|
: new InMemoryStore();
|
|
30
57
|
|
|
31
58
|
this.plugins = new PluginRegistry();
|
|
59
|
+
this.events = new DomainEventBus();
|
|
32
60
|
|
|
33
61
|
if (config.plugins) {
|
|
34
62
|
for (const plugin of config.plugins) {
|
|
@@ -85,6 +113,18 @@ export class CriticalPathEngine {
|
|
|
85
113
|
|
|
86
114
|
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
87
115
|
const created = await this.store.createWorkflow(workflow);
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
|
|
118
|
+
const event: WorkflowCreatedEvent = {
|
|
119
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
120
|
+
name: 'workflow.created',
|
|
121
|
+
aggregateId: created.id,
|
|
122
|
+
aggregateType: 'Workflow',
|
|
123
|
+
occurredAt: now,
|
|
124
|
+
payload: { workflow: created }
|
|
125
|
+
};
|
|
126
|
+
await this.events.publish(event);
|
|
127
|
+
|
|
88
128
|
await this.store.logActivity({
|
|
89
129
|
actorId: 'system',
|
|
90
130
|
action: 'workflow.created',
|
|
@@ -100,6 +140,17 @@ export class CriticalPathEngine {
|
|
|
100
140
|
|
|
101
141
|
const updated = await this.store.updateWorkflow(id, updates);
|
|
102
142
|
if (updated) {
|
|
143
|
+
const now = new Date().toISOString();
|
|
144
|
+
const event: WorkflowUpdatedEvent = {
|
|
145
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
146
|
+
name: 'workflow.updated',
|
|
147
|
+
aggregateId: updated.id,
|
|
148
|
+
aggregateType: 'Workflow',
|
|
149
|
+
occurredAt: now,
|
|
150
|
+
payload: { workflow: updated, previous: existing }
|
|
151
|
+
};
|
|
152
|
+
await this.events.publish(event);
|
|
153
|
+
|
|
103
154
|
await this.store.logActivity({
|
|
104
155
|
actorId: 'system',
|
|
105
156
|
action: 'workflow.updated',
|
|
@@ -116,6 +167,17 @@ export class CriticalPathEngine {
|
|
|
116
167
|
|
|
117
168
|
const deleted = await this.store.deleteWorkflow(id);
|
|
118
169
|
if (deleted) {
|
|
170
|
+
const now = new Date().toISOString();
|
|
171
|
+
const event: WorkflowDeletedEvent = {
|
|
172
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
173
|
+
name: 'workflow.deleted',
|
|
174
|
+
aggregateId: id,
|
|
175
|
+
aggregateType: 'Workflow',
|
|
176
|
+
occurredAt: now,
|
|
177
|
+
payload: { workflowId: id, name: existing.name }
|
|
178
|
+
};
|
|
179
|
+
await this.events.publish(event);
|
|
180
|
+
|
|
119
181
|
await this.store.logActivity({
|
|
120
182
|
actorId: 'system',
|
|
121
183
|
action: 'workflow.deleted',
|
|
@@ -160,6 +222,18 @@ export class CriticalPathEngine {
|
|
|
160
222
|
|
|
161
223
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
162
224
|
const created = await this.store.createProject(project);
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
|
|
227
|
+
const event: ProjectCreatedEvent = {
|
|
228
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
229
|
+
name: 'project.created',
|
|
230
|
+
aggregateId: created.id,
|
|
231
|
+
aggregateType: 'Project',
|
|
232
|
+
occurredAt: now,
|
|
233
|
+
payload: { project: created }
|
|
234
|
+
};
|
|
235
|
+
await this.events.publish(event);
|
|
236
|
+
|
|
163
237
|
await this.store.logActivity({
|
|
164
238
|
projectId: created.id,
|
|
165
239
|
actorId: project.ownerId || 'system',
|
|
@@ -170,6 +244,34 @@ export class CriticalPathEngine {
|
|
|
170
244
|
return created;
|
|
171
245
|
}
|
|
172
246
|
|
|
247
|
+
async updateProject(id: string, updates: Partial<Project>): Promise<Project | null> {
|
|
248
|
+
const existing = await this.store.getProject(id);
|
|
249
|
+
if (!existing) return null;
|
|
250
|
+
|
|
251
|
+
const updated = await this.store.updateProject(id, updates);
|
|
252
|
+
if (updated) {
|
|
253
|
+
const now = new Date().toISOString();
|
|
254
|
+
const event: ProjectUpdatedEvent = {
|
|
255
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
256
|
+
name: 'project.updated',
|
|
257
|
+
aggregateId: updated.id,
|
|
258
|
+
aggregateType: 'Project',
|
|
259
|
+
occurredAt: now,
|
|
260
|
+
payload: { project: updated, previous: existing }
|
|
261
|
+
};
|
|
262
|
+
await this.events.publish(event);
|
|
263
|
+
|
|
264
|
+
await this.store.logActivity({
|
|
265
|
+
projectId: updated.id,
|
|
266
|
+
actorId: 'system',
|
|
267
|
+
action: 'project.updated',
|
|
268
|
+
details: { name: updated.name }
|
|
269
|
+
});
|
|
270
|
+
this.dispatchWebhook('project.updated', { project: updated });
|
|
271
|
+
}
|
|
272
|
+
return updated;
|
|
273
|
+
}
|
|
274
|
+
|
|
173
275
|
// --- Tasks ---
|
|
174
276
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
175
277
|
return this.store.getTasks(projectId);
|
|
@@ -179,18 +281,34 @@ export class CriticalPathEngine {
|
|
|
179
281
|
return this.store.getTask(id);
|
|
180
282
|
}
|
|
181
283
|
|
|
182
|
-
async createTask(taskInput:
|
|
284
|
+
async createTask(taskInput: CreateTaskInput): Promise<Task> {
|
|
183
285
|
const processedInput = await this.plugins.runBeforeTaskCreate(taskInput);
|
|
184
286
|
const projectId = processedInput.projectId || taskInput.projectId;
|
|
287
|
+
const project = await this.store.getProject(projectId);
|
|
185
288
|
const workflow = await this.resolveProjectWorkflow(projectId);
|
|
186
289
|
|
|
290
|
+
// Validate custom field domain invariants
|
|
291
|
+
if (project?.customFieldDefinitions && (processedInput.customFields || taskInput.customFields)) {
|
|
292
|
+
validateCustomFieldValues(
|
|
293
|
+
project.customFieldDefinitions,
|
|
294
|
+
processedInput.customFields || taskInput.customFields
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
187
298
|
const defaultStatus = workflow?.defaultStatusKey || 'todo';
|
|
299
|
+
const initialStatus = processedInput.status || taskInput.status || defaultStatus;
|
|
300
|
+
const now = new Date().toISOString();
|
|
301
|
+
|
|
302
|
+
// Derive initial lifecycle timestamps
|
|
303
|
+
const statusDef = resolveStatusDefinition(initialStatus, project?.statusDefinitions || workflow?.statuses);
|
|
304
|
+
const actualStartDate = processedInput.actualStartDate ?? taskInput.actualStartDate ?? (statusDef.executionState === 'active' ? now : undefined);
|
|
305
|
+
const actualEndDate = processedInput.actualEndDate ?? taskInput.actualEndDate ?? (statusDef.completionState === 'done' ? now : undefined);
|
|
188
306
|
|
|
189
307
|
const created = await this.store.createTask({
|
|
190
308
|
projectId,
|
|
191
309
|
title: processedInput.title || taskInput.title,
|
|
192
310
|
description: processedInput.description ?? taskInput.description,
|
|
193
|
-
status:
|
|
311
|
+
status: initialStatus,
|
|
194
312
|
priority: processedInput.priority || taskInput.priority || 'medium',
|
|
195
313
|
taskType: processedInput.taskType || taskInput.taskType || 'task',
|
|
196
314
|
assigneeId: processedInput.assigneeId ?? taskInput.assigneeId,
|
|
@@ -200,8 +318,8 @@ export class CriticalPathEngine {
|
|
|
200
318
|
teamId: processedInput.teamId ?? taskInput.teamId,
|
|
201
319
|
containerId: processedInput.containerId ?? taskInput.containerId,
|
|
202
320
|
plannedStartDate: processedInput.plannedStartDate ?? taskInput.plannedStartDate,
|
|
203
|
-
actualStartDate
|
|
204
|
-
actualEndDate
|
|
321
|
+
actualStartDate,
|
|
322
|
+
actualEndDate,
|
|
205
323
|
dueDate: processedInput.dueDate ?? taskInput.dueDate,
|
|
206
324
|
estimatedHours: processedInput.estimatedHours ?? taskInput.estimatedHours,
|
|
207
325
|
loggedHours: processedInput.loggedHours ?? taskInput.loggedHours ?? 0,
|
|
@@ -210,7 +328,7 @@ export class CriticalPathEngine {
|
|
|
210
328
|
estimatedDurationMinutes: processedInput.estimatedDurationMinutes ?? taskInput.estimatedDurationMinutes,
|
|
211
329
|
actualDurationMinutes: processedInput.actualDurationMinutes ?? taskInput.actualDurationMinutes,
|
|
212
330
|
billableDurationMinutes: processedInput.billableDurationMinutes ?? taskInput.billableDurationMinutes,
|
|
213
|
-
progress: processedInput.progress ?? taskInput.progress ?? 0,
|
|
331
|
+
progress: processedInput.progress ?? taskInput.progress ?? (statusDef.completionState === 'done' ? 100 : 0),
|
|
214
332
|
tags: processedInput.tags ?? taskInput.tags ?? [],
|
|
215
333
|
customFields: processedInput.customFields ?? taskInput.customFields ?? {},
|
|
216
334
|
parentId: processedInput.parentId ?? taskInput.parentId
|
|
@@ -218,6 +336,17 @@ export class CriticalPathEngine {
|
|
|
218
336
|
|
|
219
337
|
await this.plugins.runAfterTaskCreate(created);
|
|
220
338
|
|
|
339
|
+
// Publish typed Domain Event
|
|
340
|
+
const event: TaskCreatedEvent = {
|
|
341
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
342
|
+
name: 'task.created',
|
|
343
|
+
aggregateId: created.id,
|
|
344
|
+
aggregateType: 'Task',
|
|
345
|
+
occurredAt: now,
|
|
346
|
+
payload: { task: created }
|
|
347
|
+
};
|
|
348
|
+
await this.events.publish(event);
|
|
349
|
+
|
|
221
350
|
await this.store.logActivity({
|
|
222
351
|
projectId: created.projectId,
|
|
223
352
|
taskId: created.id,
|
|
@@ -234,21 +363,83 @@ export class CriticalPathEngine {
|
|
|
234
363
|
const existing = await this.store.getTask(id);
|
|
235
364
|
if (!existing) return null;
|
|
236
365
|
|
|
366
|
+
const project = await this.store.getProject(existing.projectId);
|
|
367
|
+
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
368
|
+
|
|
369
|
+
// Validate workflow transition invariant
|
|
237
370
|
if (updates.status && updates.status !== existing.status) {
|
|
238
|
-
const workflow = await this.resolveProjectWorkflow(existing.projectId);
|
|
239
371
|
const isValid = validateTransition(workflow || undefined, existing.status, updates.status);
|
|
240
372
|
if (!isValid) {
|
|
241
373
|
throw new WorkflowValidationError(existing.status, updates.status, workflow?.id);
|
|
242
374
|
}
|
|
243
375
|
}
|
|
244
376
|
|
|
377
|
+
// Validate custom field invariants
|
|
378
|
+
if (project?.customFieldDefinitions && updates.customFields) {
|
|
379
|
+
validateCustomFieldValues(project.customFieldDefinitions, {
|
|
380
|
+
...existing.customFields,
|
|
381
|
+
...updates.customFields
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
245
385
|
const processedUpdates = await this.plugins.runBeforeTaskUpdate(id, updates);
|
|
386
|
+
|
|
387
|
+
// Auto-update execution/completion timestamps if status changes
|
|
388
|
+
if (processedUpdates.status && processedUpdates.status !== existing.status) {
|
|
389
|
+
const statusDef = resolveStatusDefinition(
|
|
390
|
+
processedUpdates.status,
|
|
391
|
+
project?.statusDefinitions || workflow?.statuses
|
|
392
|
+
);
|
|
393
|
+
const now = new Date().toISOString();
|
|
394
|
+
if (statusDef.executionState === 'active' && !existing.actualStartDate && !processedUpdates.actualStartDate) {
|
|
395
|
+
processedUpdates.actualStartDate = now;
|
|
396
|
+
}
|
|
397
|
+
if (statusDef.completionState === 'done' && !processedUpdates.actualEndDate) {
|
|
398
|
+
processedUpdates.actualEndDate = now;
|
|
399
|
+
if (processedUpdates.progress === undefined && (existing.progress || 0) < 100) {
|
|
400
|
+
processedUpdates.progress = 100;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
246
405
|
const updated = await this.store.updateTask(id, processedUpdates);
|
|
247
406
|
if (!updated) return null;
|
|
248
407
|
|
|
249
408
|
await this.plugins.runAfterTaskUpdate(updated, existing);
|
|
250
409
|
|
|
410
|
+
const now = new Date().toISOString();
|
|
251
411
|
const isStatusChange = existing.status !== updated.status;
|
|
412
|
+
|
|
413
|
+
// Publish typed Domain Events
|
|
414
|
+
if (isStatusChange) {
|
|
415
|
+
const statusEvent: TaskStatusChangedEvent = {
|
|
416
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
417
|
+
name: 'task.status_changed',
|
|
418
|
+
aggregateId: updated.id,
|
|
419
|
+
aggregateType: 'Task',
|
|
420
|
+
occurredAt: now,
|
|
421
|
+
payload: {
|
|
422
|
+
task: updated,
|
|
423
|
+
previousStatus: existing.status,
|
|
424
|
+
newStatus: updated.status
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
await this.events.publish(statusEvent);
|
|
428
|
+
} else {
|
|
429
|
+
const updateEvent: TaskUpdatedEvent = {
|
|
430
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
431
|
+
name: 'task.updated',
|
|
432
|
+
aggregateId: updated.id,
|
|
433
|
+
aggregateType: 'Task',
|
|
434
|
+
occurredAt: now,
|
|
435
|
+
payload: {
|
|
436
|
+
task: updated,
|
|
437
|
+
previous: existing
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
await this.events.publish(updateEvent);
|
|
441
|
+
}
|
|
442
|
+
|
|
252
443
|
await this.store.logActivity({
|
|
253
444
|
projectId: updated.projectId,
|
|
254
445
|
taskId: updated.id,
|
|
@@ -274,6 +465,22 @@ export class CriticalPathEngine {
|
|
|
274
465
|
|
|
275
466
|
if (deleted) {
|
|
276
467
|
await this.plugins.runAfterTaskDelete(id);
|
|
468
|
+
const now = new Date().toISOString();
|
|
469
|
+
|
|
470
|
+
const event: TaskDeletedEvent = {
|
|
471
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
472
|
+
name: 'task.deleted',
|
|
473
|
+
aggregateId: id,
|
|
474
|
+
aggregateType: 'Task',
|
|
475
|
+
occurredAt: now,
|
|
476
|
+
payload: {
|
|
477
|
+
taskId: id,
|
|
478
|
+
projectId: existing.projectId,
|
|
479
|
+
title: existing.title
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
await this.events.publish(event);
|
|
483
|
+
|
|
277
484
|
await this.store.logActivity({
|
|
278
485
|
projectId: existing.projectId,
|
|
279
486
|
taskId: id,
|
|
@@ -287,6 +494,35 @@ export class CriticalPathEngine {
|
|
|
287
494
|
}
|
|
288
495
|
|
|
289
496
|
// --- Task Dependencies & Graph ---
|
|
497
|
+
async addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency> {
|
|
498
|
+
const existingDependencies = await this.store.getDependencies(dep.taskId);
|
|
499
|
+
const allProjectDeps = [
|
|
500
|
+
...existingDependencies,
|
|
501
|
+
...(await this.store.getDependencies(dep.dependsOnTaskId))
|
|
502
|
+
];
|
|
503
|
+
|
|
504
|
+
// Enforce Directed Acyclic Graph (DAG) Invariant
|
|
505
|
+
const cycleCheck = detectDependencyCycle(allProjectDeps, dep);
|
|
506
|
+
if (cycleCheck.hasCycle) {
|
|
507
|
+
throw new CircularDependencyError(dep.taskId, dep.dependsOnTaskId, cycleCheck.cyclePath);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const created = await this.store.addDependency(dep);
|
|
511
|
+
const now = new Date().toISOString();
|
|
512
|
+
|
|
513
|
+
const event: TaskDependencyAddedEvent = {
|
|
514
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
515
|
+
name: 'dependency.added',
|
|
516
|
+
aggregateId: created.id,
|
|
517
|
+
aggregateType: 'Dependency',
|
|
518
|
+
occurredAt: now,
|
|
519
|
+
payload: { dependency: created }
|
|
520
|
+
};
|
|
521
|
+
await this.events.publish(event);
|
|
522
|
+
|
|
523
|
+
return created;
|
|
524
|
+
}
|
|
525
|
+
|
|
290
526
|
async getTaskDependencyGraph(taskId: string): Promise<TaskDependencyGraph> {
|
|
291
527
|
const dependencies = await this.store.getDependencies(taskId);
|
|
292
528
|
const upstreamTaskIds = new Set<string>();
|
|
@@ -325,6 +561,64 @@ export class CriticalPathEngine {
|
|
|
325
561
|
return deriveTaskLifecycleState(task, statusDefs);
|
|
326
562
|
}
|
|
327
563
|
|
|
564
|
+
// --- Time Tracking ---
|
|
565
|
+
async logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt' | 'userId'> & { userId?: string }): Promise<TimeEntry> {
|
|
566
|
+
if (entry.hours <= 0) {
|
|
567
|
+
throw new Error('Logged hours must be a positive number.');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const task = await this.store.getTask(entry.taskId);
|
|
571
|
+
if (task) {
|
|
572
|
+
const newLoggedHours = (task.loggedHours || 0) + entry.hours;
|
|
573
|
+
const newActualHours = (task.actualHours || 0) + entry.hours;
|
|
574
|
+
const newBillableHours = entry.isBillable !== false ? (task.billableHours || 0) + entry.hours : task.billableHours;
|
|
575
|
+
await this.store.updateTask(task.id, {
|
|
576
|
+
loggedHours: newLoggedHours,
|
|
577
|
+
actualHours: newActualHours,
|
|
578
|
+
billableHours: newBillableHours
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const fullEntry: Omit<TimeEntry, 'id' | 'loggedAt'> = {
|
|
583
|
+
...entry,
|
|
584
|
+
userId: entry.userId || task?.assigneeId || 'system'
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const created = await this.store.logTime(fullEntry);
|
|
588
|
+
const now = new Date().toISOString();
|
|
589
|
+
|
|
590
|
+
const event: TimeLoggedEvent = {
|
|
591
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
592
|
+
name: 'time.logged',
|
|
593
|
+
aggregateId: entry.taskId,
|
|
594
|
+
aggregateType: 'Task',
|
|
595
|
+
occurredAt: now,
|
|
596
|
+
payload: { taskId: entry.taskId, timeEntry: created }
|
|
597
|
+
};
|
|
598
|
+
await this.events.publish(event);
|
|
599
|
+
|
|
600
|
+
return created;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// --- Comments ---
|
|
604
|
+
async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
|
|
605
|
+
const created = await this.store.addComment(comment);
|
|
606
|
+
const now = new Date().toISOString();
|
|
607
|
+
|
|
608
|
+
const event: CommentAddedEvent = {
|
|
609
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
610
|
+
name: 'comment.created',
|
|
611
|
+
aggregateId: comment.taskId,
|
|
612
|
+
aggregateType: 'Task',
|
|
613
|
+
occurredAt: now,
|
|
614
|
+
payload: { taskId: comment.taskId, comment: created }
|
|
615
|
+
};
|
|
616
|
+
await this.events.publish(event);
|
|
617
|
+
|
|
618
|
+
this.dispatchWebhook('comment.created', { comment: created });
|
|
619
|
+
return created;
|
|
620
|
+
}
|
|
621
|
+
|
|
328
622
|
// --- Teams ---
|
|
329
623
|
async getTeams(): Promise<Team[]> {
|
|
330
624
|
return this.store.getTeams();
|
|
@@ -336,6 +630,18 @@ export class CriticalPathEngine {
|
|
|
336
630
|
|
|
337
631
|
async createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team> {
|
|
338
632
|
const created = await this.store.createTeam(team);
|
|
633
|
+
const now = new Date().toISOString();
|
|
634
|
+
|
|
635
|
+
const event: TeamCreatedEvent = {
|
|
636
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
637
|
+
name: 'team.created',
|
|
638
|
+
aggregateId: created.id,
|
|
639
|
+
aggregateType: 'Team',
|
|
640
|
+
occurredAt: now,
|
|
641
|
+
payload: { team: created }
|
|
642
|
+
};
|
|
643
|
+
await this.events.publish(event);
|
|
644
|
+
|
|
339
645
|
this.dispatchWebhook('team.created', { team: created });
|
|
340
646
|
return created;
|
|
341
647
|
}
|
|
@@ -359,6 +665,18 @@ export class CriticalPathEngine {
|
|
|
359
665
|
|
|
360
666
|
async createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer> {
|
|
361
667
|
const created = await this.store.createContainer(container);
|
|
668
|
+
const now = new Date().toISOString();
|
|
669
|
+
|
|
670
|
+
const event: ContainerCreatedEvent = {
|
|
671
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
672
|
+
name: 'container.created',
|
|
673
|
+
aggregateId: created.id,
|
|
674
|
+
aggregateType: 'Container',
|
|
675
|
+
occurredAt: now,
|
|
676
|
+
payload: { container: created }
|
|
677
|
+
};
|
|
678
|
+
await this.events.publish(event);
|
|
679
|
+
|
|
362
680
|
this.dispatchWebhook('container.created', { container: created });
|
|
363
681
|
return created;
|
|
364
682
|
}
|
|
@@ -383,6 +701,16 @@ export class CriticalPathEngine {
|
|
|
383
701
|
async createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration> {
|
|
384
702
|
const created = await this.store.createIteration(iteration);
|
|
385
703
|
if (created.status === 'active') {
|
|
704
|
+
const now = new Date().toISOString();
|
|
705
|
+
const event: IterationStartedEvent = {
|
|
706
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
707
|
+
name: 'iteration.started',
|
|
708
|
+
aggregateId: created.id,
|
|
709
|
+
aggregateType: 'Iteration',
|
|
710
|
+
occurredAt: now,
|
|
711
|
+
payload: { iteration: created }
|
|
712
|
+
};
|
|
713
|
+
await this.events.publish(event);
|
|
386
714
|
this.dispatchWebhook('iteration.started', { iteration: created });
|
|
387
715
|
}
|
|
388
716
|
return created;
|
|
@@ -394,9 +722,28 @@ export class CriticalPathEngine {
|
|
|
394
722
|
|
|
395
723
|
const updated = await this.store.updateIteration(id, updates);
|
|
396
724
|
if (updated) {
|
|
725
|
+
const now = new Date().toISOString();
|
|
397
726
|
if (existing.status !== 'active' && updated.status === 'active') {
|
|
727
|
+
const event: IterationStartedEvent = {
|
|
728
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
729
|
+
name: 'iteration.started',
|
|
730
|
+
aggregateId: updated.id,
|
|
731
|
+
aggregateType: 'Iteration',
|
|
732
|
+
occurredAt: now,
|
|
733
|
+
payload: { iteration: updated }
|
|
734
|
+
};
|
|
735
|
+
await this.events.publish(event);
|
|
398
736
|
this.dispatchWebhook('iteration.started', { iteration: updated });
|
|
399
737
|
} else if (existing.status !== 'completed' && updated.status === 'completed') {
|
|
738
|
+
const event: IterationCompletedEvent = {
|
|
739
|
+
id: `evt_${Math.random().toString(36).substring(2, 9)}`,
|
|
740
|
+
name: 'iteration.completed',
|
|
741
|
+
aggregateId: updated.id,
|
|
742
|
+
aggregateType: 'Iteration',
|
|
743
|
+
occurredAt: now,
|
|
744
|
+
payload: { iteration: updated }
|
|
745
|
+
};
|
|
746
|
+
await this.events.publish(event);
|
|
400
747
|
this.dispatchWebhook('iteration.completed', { iteration: updated });
|
|
401
748
|
}
|
|
402
749
|
}
|
package/src/index.ts
CHANGED
package/src/store/index.ts
CHANGED
|
@@ -16,68 +16,92 @@ import type {
|
|
|
16
16
|
} from '../types/index.js';
|
|
17
17
|
import { generateProjectKey } from '../utils/key.js';
|
|
18
18
|
|
|
19
|
-
export interface
|
|
20
|
-
// Projects
|
|
19
|
+
export interface ProjectRepository {
|
|
21
20
|
getProjects(): Promise<Project[]>;
|
|
22
21
|
getProject(id: string): Promise<Project | null>;
|
|
23
22
|
createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project>;
|
|
24
23
|
updateProject(id: string, updates: Partial<Project>): Promise<Project | null>;
|
|
25
24
|
deleteProject(id: string): Promise<boolean>;
|
|
25
|
+
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
export interface WorkflowRepository {
|
|
28
28
|
getWorkflows(): Promise<Workflow[]>;
|
|
29
29
|
getWorkflow(id: string): Promise<Workflow | null>;
|
|
30
30
|
createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow>;
|
|
31
31
|
updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null>;
|
|
32
32
|
deleteWorkflow(id: string): Promise<boolean>;
|
|
33
|
+
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
export interface TaskRepository {
|
|
35
36
|
getTasks(projectId?: string): Promise<Task[]>;
|
|
36
37
|
getTask(id: string): Promise<Task | null>;
|
|
37
38
|
createTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Promise<Task>;
|
|
38
39
|
updateTask(id: string, updates: Partial<Task>): Promise<Task | null>;
|
|
39
40
|
deleteTask(id: string): Promise<boolean>;
|
|
41
|
+
}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
export interface TeamRepository {
|
|
42
44
|
getTeams(): Promise<Team[]>;
|
|
43
45
|
getTeam(id: string): Promise<Team | null>;
|
|
44
46
|
createTeam(team: Omit<Team, 'id' | 'createdAt' | 'updatedAt'>): Promise<Team>;
|
|
45
47
|
updateTeam(id: string, updates: Partial<Team>): Promise<Team | null>;
|
|
46
48
|
deleteTeam(id: string): Promise<boolean>;
|
|
49
|
+
}
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
export interface ContainerRepository {
|
|
49
52
|
getContainers(projectId: string): Promise<TaskContainer[]>;
|
|
50
53
|
getContainer(id: string): Promise<TaskContainer | null>;
|
|
51
54
|
createContainer(container: Omit<TaskContainer, 'id' | 'createdAt' | 'updatedAt'>): Promise<TaskContainer>;
|
|
52
55
|
updateContainer(id: string, updates: Partial<TaskContainer>): Promise<TaskContainer | null>;
|
|
53
56
|
deleteContainer(id: string): Promise<boolean>;
|
|
57
|
+
}
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
export interface IterationRepository {
|
|
56
60
|
getIterations(projectId: string): Promise<Iteration[]>;
|
|
57
61
|
getIteration(id: string): Promise<Iteration | null>;
|
|
58
62
|
createIteration(iteration: Omit<Iteration, 'id' | 'createdAt'>): Promise<Iteration>;
|
|
59
63
|
updateIteration(id: string, updates: Partial<Iteration>): Promise<Iteration | null>;
|
|
60
64
|
deleteIteration(id: string): Promise<boolean>;
|
|
65
|
+
}
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
export interface CommentRepository {
|
|
63
68
|
getComments(taskId: string): Promise<Comment[]>;
|
|
64
69
|
addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ActivityRepository {
|
|
65
73
|
getActivities(filter?: { projectId?: string; taskId?: string }): Promise<Activity[]>;
|
|
66
74
|
logActivity(activity: Omit<Activity, 'id' | 'createdAt'>): Promise<Activity>;
|
|
75
|
+
}
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
export interface TimeEntryRepository {
|
|
69
78
|
getTimeEntries(taskId: string): Promise<TimeEntry[]>;
|
|
70
79
|
logTime(entry: Omit<TimeEntry, 'id' | 'loggedAt'>): Promise<TimeEntry>;
|
|
80
|
+
}
|
|
71
81
|
|
|
72
|
-
|
|
82
|
+
export interface DependencyRepository {
|
|
73
83
|
getDependencies(taskId: string): Promise<TaskDependency[]>;
|
|
74
84
|
addDependency(dep: Omit<TaskDependency, 'id'>): Promise<TaskDependency>;
|
|
85
|
+
}
|
|
75
86
|
|
|
76
|
-
|
|
87
|
+
export interface WebhookRepository {
|
|
77
88
|
getWebhooks(): Promise<Webhook[]>;
|
|
78
89
|
addWebhook(webhook: Omit<Webhook, 'id' | 'createdAt'>): Promise<Webhook>;
|
|
79
90
|
}
|
|
80
91
|
|
|
92
|
+
export interface StorageAdapter
|
|
93
|
+
extends ProjectRepository,
|
|
94
|
+
WorkflowRepository,
|
|
95
|
+
TaskRepository,
|
|
96
|
+
TeamRepository,
|
|
97
|
+
ContainerRepository,
|
|
98
|
+
IterationRepository,
|
|
99
|
+
CommentRepository,
|
|
100
|
+
ActivityRepository,
|
|
101
|
+
TimeEntryRepository,
|
|
102
|
+
DependencyRepository,
|
|
103
|
+
WebhookRepository {}
|
|
104
|
+
|
|
81
105
|
export class InMemoryStore implements StorageAdapter {
|
|
82
106
|
private projects = new Map<string, Project>();
|
|
83
107
|
private workflows = new Map<string, Workflow>();
|
package/src/types/index.ts
CHANGED
|
@@ -155,6 +155,13 @@ export interface Task {
|
|
|
155
155
|
updatedAt: string;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
export type CreateTaskInput = Omit<Task, 'id' | 'createdAt' | 'updatedAt' | 'status' | 'priority'> & {
|
|
159
|
+
status?: TaskStatus;
|
|
160
|
+
priority?: Priority;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export type CreateProjectInput = Omit<Project, 'id' | 'createdAt' | 'updatedAt'>;
|
|
164
|
+
|
|
158
165
|
export interface Iteration {
|
|
159
166
|
id: string;
|
|
160
167
|
projectId: string;
|
package/src/utils/workflow.ts
CHANGED
package/tsconfig.tsbuildinfo
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["./src/index.test.ts","./src/index.ts","./src/use-case-validation.test.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|
|
1
|
+
{"root":["./src/index.test.ts","./src/index.ts","./src/use-case-validation.test.ts","./src/domain/custom-fields.ts","./src/domain/ddd.test.ts","./src/domain/entities.ts","./src/domain/events.ts","./src/domain/graph.ts","./src/domain/index.ts","./src/engine/index.ts","./src/plugins/index.ts","./src/store/firebase.test.ts","./src/store/firebase.ts","./src/store/index.ts","./src/store/sqlite.test.ts","./src/store/sqlite.ts","./src/types/index.ts","./src/utils/index.ts","./src/utils/key.test.ts","./src/utils/key.ts","./src/utils/status.ts","./src/utils/workflow.ts"],"version":"5.9.3"}
|