@critical-path/core 0.8.3 → 0.10.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 (44) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/dist/domain/ddd.test.js +38 -0
  4. package/dist/domain/ddd.test.js.map +1 -1
  5. package/dist/domain/entities.d.ts +5 -1
  6. package/dist/domain/entities.d.ts.map +1 -1
  7. package/dist/domain/entities.js +75 -0
  8. package/dist/domain/entities.js.map +1 -1
  9. package/dist/domain/events.d.ts +21 -2
  10. package/dist/domain/events.d.ts.map +1 -1
  11. package/dist/domain/events.js.map +1 -1
  12. package/dist/engine/index.d.ts +8 -0
  13. package/dist/engine/index.d.ts.map +1 -1
  14. package/dist/engine/index.js +70 -0
  15. package/dist/engine/index.js.map +1 -1
  16. package/dist/index.test.js +46 -0
  17. package/dist/index.test.js.map +1 -1
  18. package/dist/store/firebase.d.ts +8 -0
  19. package/dist/store/firebase.d.ts.map +1 -1
  20. package/dist/store/firebase.js +22 -0
  21. package/dist/store/firebase.js.map +1 -1
  22. package/dist/store/index.d.ts +16 -0
  23. package/dist/store/index.d.ts.map +1 -1
  24. package/dist/store/index.js +34 -0
  25. package/dist/store/index.js.map +1 -1
  26. package/dist/store/sqlite.d.ts +9 -0
  27. package/dist/store/sqlite.d.ts.map +1 -1
  28. package/dist/store/sqlite.js +50 -12
  29. package/dist/store/sqlite.js.map +1 -1
  30. package/dist/store/sqlite.test.js +8 -0
  31. package/dist/store/sqlite.test.js.map +1 -1
  32. package/dist/types/index.d.ts +15 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/domain/ddd.test.ts +46 -0
  36. package/src/domain/entities.ts +82 -0
  37. package/src/domain/events.ts +13 -0
  38. package/src/engine/index.ts +78 -0
  39. package/src/index.test.ts +53 -0
  40. package/src/store/firebase.ts +24 -0
  41. package/src/store/index.ts +38 -0
  42. package/src/store/sqlite.test.ts +11 -0
  43. package/src/store/sqlite.ts +53 -10
  44. package/src/types/index.ts +18 -0
@@ -8,6 +8,7 @@ import type {
8
8
  TaskDependency,
9
9
  TimeEntry,
10
10
  Comment,
11
+ CommentReaction,
11
12
  Attachment
12
13
  } from '../types/index.js';
13
14
 
@@ -65,6 +66,16 @@ export interface CommentDeletedEvent extends DomainEvent<{ commentId: string; ta
65
66
  readonly aggregateType: 'Comment';
66
67
  }
67
68
 
69
+ export interface CommentReactionAddedEvent extends DomainEvent<{ commentId: string; reaction: CommentReaction; comment: Comment }> {
70
+ readonly name: 'comment.reaction.added';
71
+ readonly aggregateType: 'Comment';
72
+ }
73
+
74
+ export interface CommentReactionRemovedEvent extends DomainEvent<{ commentId: string; reaction: { emoji: string; userId: string }; comment: Comment }> {
75
+ readonly name: 'comment.reaction.removed';
76
+ readonly aggregateType: 'Comment';
77
+ }
78
+
68
79
  export interface AttachmentCreatedEvent extends DomainEvent<{ attachment: Attachment }> {
69
80
  readonly name: 'attachment.created';
70
81
  readonly aggregateType: 'Attachment';
@@ -134,6 +145,8 @@ export type CriticalPathDomainEvent =
134
145
  | CommentAddedEvent
135
146
  | CommentUpdatedEvent
136
147
  | CommentDeletedEvent
148
+ | CommentReactionAddedEvent
149
+ | CommentReactionRemovedEvent
137
150
  | AttachmentCreatedEvent
138
151
  | AttachmentDeletedEvent
139
152
  | TaskDependencyAddedEvent
@@ -48,6 +48,8 @@ import {
48
48
  CommentAddedEvent,
49
49
  CommentUpdatedEvent,
50
50
  CommentDeletedEvent,
51
+ CommentReactionAddedEvent,
52
+ CommentReactionRemovedEvent,
51
53
  AttachmentCreatedEvent,
52
54
  AttachmentDeletedEvent
53
55
  } from '../domain/events.js';
@@ -683,6 +685,82 @@ export class CriticalPathEngine {
683
685
  return deleted;
684
686
  }
685
687
 
688
+ async addCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
689
+ const existing = await this.store.getComment(commentId);
690
+ if (!existing) return null;
691
+
692
+ let updated: Comment | null;
693
+ if (this.store.addReaction) {
694
+ updated = await this.store.addReaction(commentId, reaction);
695
+ } else {
696
+ const reactions = existing.reactions ? [...existing.reactions] : [];
697
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
698
+ if (!alreadyExists) {
699
+ reactions.push({
700
+ emoji: reaction.emoji,
701
+ userId: reaction.userId,
702
+ createdAt: new Date().toISOString()
703
+ });
704
+ }
705
+ updated = await this.store.updateComment(commentId, { reactions });
706
+ }
707
+
708
+ if (!updated) return null;
709
+
710
+ const now = new Date().toISOString();
711
+ const event: CommentReactionAddedEvent = {
712
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
713
+ name: 'comment.reaction.added',
714
+ aggregateId: commentId,
715
+ aggregateType: 'Comment',
716
+ occurredAt: now,
717
+ payload: {
718
+ commentId,
719
+ reaction: {
720
+ emoji: reaction.emoji,
721
+ userId: reaction.userId,
722
+ createdAt: now
723
+ },
724
+ comment: updated
725
+ }
726
+ };
727
+ await this.events.publish(event);
728
+
729
+ this.dispatchWebhook('comment.reaction.added', { comment: updated, reaction });
730
+ return updated;
731
+ }
732
+
733
+ async removeCommentReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
734
+ const existing = await this.store.getComment(commentId);
735
+ if (!existing) return null;
736
+
737
+ let updated: Comment | null;
738
+ if (this.store.removeReaction) {
739
+ updated = await this.store.removeReaction(commentId, reaction);
740
+ } else {
741
+ const reactions = (existing.reactions || []).filter(
742
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
743
+ );
744
+ updated = await this.store.updateComment(commentId, { reactions });
745
+ }
746
+
747
+ if (!updated) return null;
748
+
749
+ const now = new Date().toISOString();
750
+ const event: CommentReactionRemovedEvent = {
751
+ id: `evt_${Math.random().toString(36).substring(2, 9)}`,
752
+ name: 'comment.reaction.removed',
753
+ aggregateId: commentId,
754
+ aggregateType: 'Comment',
755
+ occurredAt: now,
756
+ payload: { commentId, reaction, comment: updated }
757
+ };
758
+ await this.events.publish(event);
759
+
760
+ this.dispatchWebhook('comment.reaction.removed', { comment: updated, reaction });
761
+ return updated;
762
+ }
763
+
686
764
  // --- Attachments ---
687
765
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
688
766
  return this.store.getAttachments(filter);
package/src/index.test.ts CHANGED
@@ -257,5 +257,58 @@ describe('CriticalPathEngine Core Tests', () => {
257
257
  })
258
258
  ).rejects.toThrow('Attachment URL cannot be a large data URI');
259
259
  });
260
+
261
+ it('supports adding and removing emoji reactions on comments with events and deduplication', async () => {
262
+ const engine = new CriticalPathEngine();
263
+ const proj = await engine.createProject({ key: 'CMT', name: 'Comment Project' });
264
+ const task = await engine.createTask({ projectId: proj.id, title: 'Comment Task', status: 'todo' });
265
+
266
+ const comment = await engine.addComment({
267
+ taskId: task.id,
268
+ authorId: 'user_1',
269
+ content: 'Great progress!'
270
+ });
271
+
272
+ const receivedEvents: string[] = [];
273
+ engine.events.subscribe('*', (event) => {
274
+ receivedEvents.push(event.name);
275
+ });
276
+
277
+ // Add reaction 👍
278
+ const withReaction = await engine.addCommentReaction(comment.id, {
279
+ emoji: '👍',
280
+ userId: 'user_2'
281
+ });
282
+ expect(withReaction?.reactions).toHaveLength(1);
283
+ expect(withReaction?.reactions?.[0]).toMatchObject({
284
+ emoji: '👍',
285
+ userId: 'user_2'
286
+ });
287
+ expect(withReaction?.reactions?.[0].createdAt).toBeDefined();
288
+ expect(receivedEvents).toContain('comment.reaction.added');
289
+
290
+ // Add duplicate reaction from same user (should deduplicate)
291
+ const duplicateReaction = await engine.addCommentReaction(comment.id, {
292
+ emoji: '👍',
293
+ userId: 'user_2'
294
+ });
295
+ expect(duplicateReaction?.reactions).toHaveLength(1);
296
+
297
+ // Add different reaction from another user
298
+ const secondReaction = await engine.addCommentReaction(comment.id, {
299
+ emoji: '🚀',
300
+ userId: 'user_3'
301
+ });
302
+ expect(secondReaction?.reactions).toHaveLength(2);
303
+
304
+ // Remove reaction 👍
305
+ const afterRemoval = await engine.removeCommentReaction(comment.id, {
306
+ emoji: '👍',
307
+ userId: 'user_2'
308
+ });
309
+ expect(afterRemoval?.reactions).toHaveLength(1);
310
+ expect(afterRemoval?.reactions?.[0].emoji).toBe('🚀');
311
+ expect(receivedEvents).toContain('comment.reaction.removed');
312
+ });
260
313
  });
261
314
 
@@ -404,6 +404,30 @@ export class FirebaseStore implements StorageAdapter {
404
404
  return true;
405
405
  }
406
406
 
407
+ async addReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
408
+ const existing = await this.getComment(commentId);
409
+ if (!existing) return null;
410
+ const reactions = existing.reactions ? [...existing.reactions] : [];
411
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
412
+ if (!alreadyExists) {
413
+ reactions.push({
414
+ emoji: reaction.emoji,
415
+ userId: reaction.userId,
416
+ createdAt: new Date().toISOString()
417
+ });
418
+ }
419
+ return this.updateComment(commentId, { reactions });
420
+ }
421
+
422
+ async removeReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
423
+ const existing = await this.getComment(commentId);
424
+ if (!existing) return null;
425
+ const reactions = (existing.reactions || []).filter(
426
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
427
+ );
428
+ return this.updateComment(commentId, { reactions });
429
+ }
430
+
407
431
  // --- Attachments ---
408
432
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
409
433
  let snap;
@@ -71,6 +71,8 @@ export interface CommentRepository {
71
71
  addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment>;
72
72
  updateComment(id: string, updates: Partial<Comment>): Promise<Comment | null>;
73
73
  deleteComment(id: string): Promise<boolean>;
74
+ addReaction?(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null>;
75
+ removeReaction?(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null>;
74
76
  }
75
77
 
76
78
  export interface AttachmentRepository {
@@ -369,6 +371,42 @@ export class InMemoryStore implements StorageAdapter {
369
371
  return this.comments.delete(id);
370
372
  }
371
373
 
374
+ async addReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
375
+ const existing = this.comments.get(commentId);
376
+ if (!existing) return null;
377
+ const reactions = existing.reactions ? [...existing.reactions] : [];
378
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
379
+ if (!alreadyExists) {
380
+ reactions.push({
381
+ emoji: reaction.emoji,
382
+ userId: reaction.userId,
383
+ createdAt: new Date().toISOString()
384
+ });
385
+ }
386
+ const updated: Comment = {
387
+ ...existing,
388
+ reactions,
389
+ updatedAt: new Date().toISOString()
390
+ };
391
+ this.comments.set(commentId, updated);
392
+ return updated;
393
+ }
394
+
395
+ async removeReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
396
+ const existing = this.comments.get(commentId);
397
+ if (!existing) return null;
398
+ const reactions = (existing.reactions || []).filter(
399
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
400
+ );
401
+ const updated: Comment = {
402
+ ...existing,
403
+ reactions,
404
+ updatedAt: new Date().toISOString()
405
+ };
406
+ this.comments.set(commentId, updated);
407
+ return updated;
408
+ }
409
+
372
410
  // Attachments
373
411
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
374
412
  return Array.from(this.attachments.values()).filter((a) => {
@@ -94,6 +94,17 @@ describe('SQLiteStore', () => {
94
94
  const entries = await store.getTimeEntries('task_1');
95
95
  expect(entries).toHaveLength(1);
96
96
 
97
+ // Emoji reactions
98
+ const reactComment = await store.addReaction(comment.id, { emoji: '🔥', userId: 'user_2' });
99
+ expect(reactComment?.reactions).toHaveLength(1);
100
+ expect(reactComment?.reactions?.[0].emoji).toBe('🔥');
101
+
102
+ const fetchedWithReactions = await store.getComment(comment.id);
103
+ expect(fetchedWithReactions?.reactions).toHaveLength(1);
104
+
105
+ const unreactComment = await store.removeReaction(comment.id, { emoji: '🔥', userId: 'user_2' });
106
+ expect(unreactComment?.reactions).toHaveLength(0);
107
+
97
108
  const deletedComment = await store.deleteComment(reply.id);
98
109
  expect(deletedComment).toBe(true);
99
110
  expect(await store.getComments('task_1')).toHaveLength(1);
@@ -154,6 +154,7 @@ export class SQLiteStore implements StorageAdapter {
154
154
  authorType TEXT,
155
155
  parentId TEXT,
156
156
  content TEXT NOT NULL,
157
+ reactions TEXT,
157
158
  metadata TEXT,
158
159
  createdAt TEXT NOT NULL,
159
160
  updatedAt TEXT NOT NULL
@@ -212,6 +213,12 @@ export class SQLiteStore implements StorageAdapter {
212
213
  createdAt TEXT NOT NULL
213
214
  );
214
215
  `);
216
+
217
+ try {
218
+ this.db.exec('ALTER TABLE comments ADD COLUMN reactions TEXT');
219
+ } catch {
220
+ // Column may already exist
221
+ }
215
222
  }
216
223
 
217
224
  // --- Projects ---
@@ -682,23 +689,32 @@ export class SQLiteStore implements StorageAdapter {
682
689
  }
683
690
 
684
691
  // --- Comments ---
692
+ private mapComment(row: any): Comment {
693
+ return {
694
+ id: row.id,
695
+ taskId: row.taskId,
696
+ authorId: row.authorId,
697
+ authorType: row.authorType,
698
+ parentId: row.parentId || undefined,
699
+ content: row.content,
700
+ reactions: row.reactions ? JSON.parse(row.reactions) : undefined,
701
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
702
+ createdAt: row.createdAt,
703
+ updatedAt: row.updatedAt
704
+ };
705
+ }
706
+
685
707
  async getComments(taskId: string): Promise<Comment[]> {
686
708
  const stmt = this.db.prepare('SELECT * FROM comments WHERE taskId = ? ORDER BY createdAt ASC');
687
709
  const rows = stmt.all(taskId) as any[];
688
- return rows.map((r) => ({
689
- ...r,
690
- metadata: r.metadata ? JSON.parse(r.metadata) : undefined
691
- }));
710
+ return rows.map((r) => this.mapComment(r));
692
711
  }
693
712
 
694
713
  async getComment(id: string): Promise<Comment | null> {
695
714
  const stmt = this.db.prepare('SELECT * FROM comments WHERE id = ?');
696
715
  const row = stmt.get(id) as any;
697
716
  if (!row) return null;
698
- return {
699
- ...row,
700
- metadata: row.metadata ? JSON.parse(row.metadata) : undefined
701
- };
717
+ return this.mapComment(row);
702
718
  }
703
719
 
704
720
  async addComment(comment: Omit<Comment, 'id' | 'createdAt' | 'updatedAt'>): Promise<Comment> {
@@ -707,8 +723,8 @@ export class SQLiteStore implements StorageAdapter {
707
723
  const newComment: Comment = { ...comment, id, createdAt: now, updatedAt: now };
708
724
 
709
725
  const stmt = this.db.prepare(`
710
- INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, metadata, createdAt, updatedAt)
711
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
726
+ INSERT INTO comments (id, taskId, authorId, authorType, parentId, content, reactions, metadata, createdAt, updatedAt)
727
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
712
728
  `);
713
729
  stmt.run(
714
730
  newComment.id,
@@ -717,6 +733,7 @@ export class SQLiteStore implements StorageAdapter {
717
733
  newComment.authorType || 'user',
718
734
  newComment.parentId || null,
719
735
  newComment.content,
736
+ newComment.reactions ? JSON.stringify(newComment.reactions) : null,
720
737
  newComment.metadata ? JSON.stringify(newComment.metadata) : null,
721
738
  newComment.createdAt,
722
739
  newComment.updatedAt
@@ -740,6 +757,7 @@ export class SQLiteStore implements StorageAdapter {
740
757
  authorType = ?,
741
758
  parentId = ?,
742
759
  content = ?,
760
+ reactions = ?,
743
761
  metadata = ?,
744
762
  updatedAt = ?
745
763
  WHERE id = ?
@@ -749,6 +767,7 @@ export class SQLiteStore implements StorageAdapter {
749
767
  updated.authorType || 'user',
750
768
  updated.parentId || null,
751
769
  updated.content,
770
+ updated.reactions ? JSON.stringify(updated.reactions) : null,
752
771
  updated.metadata ? JSON.stringify(updated.metadata) : null,
753
772
  updated.updatedAt,
754
773
  id
@@ -762,6 +781,30 @@ export class SQLiteStore implements StorageAdapter {
762
781
  return (result.changes ?? 0) > 0;
763
782
  }
764
783
 
784
+ async addReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
785
+ const existing = await this.getComment(commentId);
786
+ if (!existing) return null;
787
+ const reactions = existing.reactions ? [...existing.reactions] : [];
788
+ const alreadyExists = reactions.some((r) => r.emoji === reaction.emoji && r.userId === reaction.userId);
789
+ if (!alreadyExists) {
790
+ reactions.push({
791
+ emoji: reaction.emoji,
792
+ userId: reaction.userId,
793
+ createdAt: new Date().toISOString()
794
+ });
795
+ }
796
+ return this.updateComment(commentId, { reactions });
797
+ }
798
+
799
+ async removeReaction(commentId: string, reaction: { emoji: string; userId: string }): Promise<Comment | null> {
800
+ const existing = await this.getComment(commentId);
801
+ if (!existing) return null;
802
+ const reactions = (existing.reactions || []).filter(
803
+ (r) => !(r.emoji === reaction.emoji && r.userId === reaction.userId)
804
+ );
805
+ return this.updateComment(commentId, { reactions });
806
+ }
807
+
765
808
  // --- Attachments ---
766
809
  async getAttachments(filter?: { taskId?: string; projectId?: string; commentId?: string }): Promise<Attachment[]> {
767
810
  let sql = 'SELECT * FROM attachments';
@@ -119,6 +119,14 @@ export interface TaskDependencyGraph {
119
119
  dependencies: TaskDependency[];
120
120
  }
121
121
 
122
+ export interface TaskTodoItem {
123
+ id: string;
124
+ title: string;
125
+ completed: boolean;
126
+ createdAt?: string;
127
+ completedAt?: string;
128
+ }
129
+
122
130
  export interface Task {
123
131
  id: string;
124
132
  projectId: string;
@@ -149,6 +157,7 @@ export interface Task {
149
157
  // Progress (0 to 100 percentage)
150
158
  progress?: number;
151
159
  tags?: string[];
160
+ todos?: TaskTodoItem[];
152
161
  customFields?: Record<string, unknown>;
153
162
  parentId?: string; // Subtask support
154
163
  createdAt: string;
@@ -176,6 +185,12 @@ export interface Iteration {
176
185
 
177
186
  export type AuthorType = 'user' | 'agent' | 'system';
178
187
 
188
+ export interface CommentReaction {
189
+ emoji: string;
190
+ userId: string;
191
+ createdAt?: string;
192
+ }
193
+
179
194
  export interface Comment {
180
195
  id: string;
181
196
  taskId: string;
@@ -183,6 +198,7 @@ export interface Comment {
183
198
  authorType?: AuthorType;
184
199
  parentId?: string; // Threaded reply support
185
200
  content: string;
201
+ reactions?: CommentReaction[];
186
202
  metadata?: Record<string, unknown>;
187
203
  createdAt: string;
188
204
  updatedAt: string;
@@ -282,6 +298,8 @@ export type WebhookEvent =
282
298
  | 'comment.created'
283
299
  | 'comment.updated'
284
300
  | 'comment.deleted'
301
+ | 'comment.reaction.added'
302
+ | 'comment.reaction.removed'
285
303
  | 'attachment.created'
286
304
  | 'attachment.deleted'
287
305
  | 'iteration.started'