@critical-path/core 0.2.1 → 0.4.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 +12 -0
- package/README.md +1 -1
- package/dist/engine/index.d.ts +8 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +90 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.test.js +44 -0
- package/dist/index.test.js.map +1 -1
- package/dist/store/firebase.d.ts +6 -1
- package/dist/store/firebase.d.ts.map +1 -1
- package/dist/store/firebase.js +40 -1
- package/dist/store/firebase.js.map +1 -1
- package/dist/store/firebase.test.js +16 -0
- package/dist/store/firebase.test.js.map +1 -1
- package/dist/store/index.d.ts +12 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +38 -0
- package/dist/store/index.js.map +1 -1
- package/dist/store/sqlite.d.ts +7 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +84 -11
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sqlite.test.js +21 -0
- package/dist/store/sqlite.test.js.map +1 -1
- package/dist/types/index.d.ts +34 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +4 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/key.d.ts +14 -0
- package/dist/utils/key.d.ts.map +1 -0
- package/dist/utils/key.js +49 -0
- package/dist/utils/key.js.map +1 -0
- package/dist/utils/key.test.d.ts +2 -0
- package/dist/utils/key.test.d.ts.map +1 -0
- package/dist/utils/key.test.js +48 -0
- package/dist/utils/key.test.js.map +1 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/dist/utils/workflow.d.ts.map +1 -0
- package/dist/utils/workflow.js +101 -0
- package/dist/utils/workflow.js.map +1 -0
- package/package.json +1 -1
- package/src/engine/index.ts +103 -3
- package/src/index.test.ts +54 -0
- package/src/index.ts +2 -1
- package/src/store/firebase.test.ts +21 -0
- package/src/store/firebase.ts +45 -2
- package/src/store/index.ts +51 -1
- package/src/store/sqlite.test.ts +28 -0
- package/src/store/sqlite.ts +119 -10
- package/src/types/index.ts +40 -2
- package/src/utils/index.ts +4 -0
- package/src/utils/key.test.ts +55 -0
- package/src/utils/key.ts +51 -0
- package/src/utils/workflow.ts +128 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/store/index.ts
CHANGED
|
@@ -11,8 +11,10 @@ import type {
|
|
|
11
11
|
TimeEntry,
|
|
12
12
|
Activity,
|
|
13
13
|
Webhook,
|
|
14
|
-
TaskDependency
|
|
14
|
+
TaskDependency,
|
|
15
|
+
Workflow
|
|
15
16
|
} from '../types/index.js';
|
|
17
|
+
import { generateProjectKey } from '../utils/key.js';
|
|
16
18
|
|
|
17
19
|
export interface StorageAdapter {
|
|
18
20
|
// Projects
|
|
@@ -22,6 +24,13 @@ export interface StorageAdapter {
|
|
|
22
24
|
updateProject(id: string, updates: Partial<Project>): Promise<Project | null>;
|
|
23
25
|
deleteProject(id: string): Promise<boolean>;
|
|
24
26
|
|
|
27
|
+
// Workflows
|
|
28
|
+
getWorkflows(): Promise<Workflow[]>;
|
|
29
|
+
getWorkflow(id: string): Promise<Workflow | null>;
|
|
30
|
+
createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow>;
|
|
31
|
+
updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null>;
|
|
32
|
+
deleteWorkflow(id: string): Promise<boolean>;
|
|
33
|
+
|
|
25
34
|
// Tasks
|
|
26
35
|
getTasks(projectId?: string): Promise<Task[]>;
|
|
27
36
|
getTask(id: string): Promise<Task | null>;
|
|
@@ -71,6 +80,7 @@ export interface StorageAdapter {
|
|
|
71
80
|
|
|
72
81
|
export class InMemoryStore implements StorageAdapter {
|
|
73
82
|
private projects = new Map<string, Project>();
|
|
83
|
+
private workflows = new Map<string, Workflow>();
|
|
74
84
|
private tasks = new Map<string, Task>();
|
|
75
85
|
private teams = new Map<string, Team>();
|
|
76
86
|
private containers = new Map<string, TaskContainer>();
|
|
@@ -92,8 +102,10 @@ export class InMemoryStore implements StorageAdapter {
|
|
|
92
102
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
93
103
|
const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
|
|
94
104
|
const now = new Date().toISOString();
|
|
105
|
+
const key = project.key || generateProjectKey(project.name);
|
|
95
106
|
const newProject: Project = {
|
|
96
107
|
...project,
|
|
108
|
+
key,
|
|
97
109
|
id,
|
|
98
110
|
createdAt: now,
|
|
99
111
|
updatedAt: now
|
|
@@ -118,6 +130,44 @@ export class InMemoryStore implements StorageAdapter {
|
|
|
118
130
|
return this.projects.delete(id);
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
// Workflows
|
|
134
|
+
async getWorkflows(): Promise<Workflow[]> {
|
|
135
|
+
return Array.from(this.workflows.values());
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getWorkflow(id: string): Promise<Workflow | null> {
|
|
139
|
+
return this.workflows.get(id) || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
143
|
+
const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
|
|
144
|
+
const now = new Date().toISOString();
|
|
145
|
+
const newWorkflow: Workflow = {
|
|
146
|
+
...workflow,
|
|
147
|
+
id,
|
|
148
|
+
createdAt: now,
|
|
149
|
+
updatedAt: now
|
|
150
|
+
};
|
|
151
|
+
this.workflows.set(id, newWorkflow);
|
|
152
|
+
return newWorkflow;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
|
|
156
|
+
const existing = this.workflows.get(id);
|
|
157
|
+
if (!existing) return null;
|
|
158
|
+
const updated: Workflow = {
|
|
159
|
+
...existing,
|
|
160
|
+
...updates,
|
|
161
|
+
updatedAt: new Date().toISOString()
|
|
162
|
+
};
|
|
163
|
+
this.workflows.set(id, updated);
|
|
164
|
+
return updated;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async deleteWorkflow(id: string): Promise<boolean> {
|
|
168
|
+
return this.workflows.delete(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
121
171
|
async getTasks(projectId?: string): Promise<Task[]> {
|
|
122
172
|
const all = Array.from(this.tasks.values());
|
|
123
173
|
if (projectId) {
|
package/src/store/sqlite.test.ts
CHANGED
|
@@ -75,4 +75,32 @@ describe('SQLiteStore', () => {
|
|
|
75
75
|
const entries = await store.getTimeEntries('task_1');
|
|
76
76
|
expect(entries).toHaveLength(1);
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it('should create, retrieve, update, and delete workflows', async () => {
|
|
80
|
+
const workflow = await store.createWorkflow({
|
|
81
|
+
name: 'SQLite Workflow',
|
|
82
|
+
isDefault: true,
|
|
83
|
+
defaultStatusKey: 'todo',
|
|
84
|
+
statuses: [{ key: 'todo', label: 'To Do', completionState: 'not_done', executionState: 'inactive' }],
|
|
85
|
+
transitions: []
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(workflow.id).toMatch(/^wf_/);
|
|
89
|
+
expect(workflow.name).toBe('SQLite Workflow');
|
|
90
|
+
|
|
91
|
+
const fetched = await store.getWorkflow(workflow.id);
|
|
92
|
+
expect(fetched?.isDefault).toBe(true);
|
|
93
|
+
|
|
94
|
+
const updated = await store.updateWorkflow(workflow.id, { name: 'Updated SQLite Workflow' });
|
|
95
|
+
expect(updated?.name).toBe('Updated SQLite Workflow');
|
|
96
|
+
|
|
97
|
+
const all = await store.getWorkflows();
|
|
98
|
+
expect(all).toHaveLength(1);
|
|
99
|
+
|
|
100
|
+
const deleted = await store.deleteWorkflow(workflow.id);
|
|
101
|
+
expect(deleted).toBe(true);
|
|
102
|
+
|
|
103
|
+
const remaining = await store.getWorkflows();
|
|
104
|
+
expect(remaining).toHaveLength(0);
|
|
105
|
+
});
|
|
78
106
|
});
|
package/src/store/sqlite.ts
CHANGED
|
@@ -10,8 +10,10 @@ import type {
|
|
|
10
10
|
TimeEntry,
|
|
11
11
|
Activity,
|
|
12
12
|
Webhook,
|
|
13
|
-
TaskDependency
|
|
13
|
+
TaskDependency,
|
|
14
|
+
Workflow
|
|
14
15
|
} from '../types/index.js';
|
|
16
|
+
import { generateProjectKey } from '../utils/key.js';
|
|
15
17
|
|
|
16
18
|
export interface SQLiteStoreConfig {
|
|
17
19
|
/**
|
|
@@ -47,6 +49,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
47
49
|
ownerId TEXT,
|
|
48
50
|
members TEXT,
|
|
49
51
|
teamIds TEXT,
|
|
52
|
+
workflowId TEXT,
|
|
53
|
+
taskTypes TEXT,
|
|
50
54
|
statusDefinitions TEXT,
|
|
51
55
|
priorityDefinitions TEXT,
|
|
52
56
|
customFieldDefinitions TEXT,
|
|
@@ -54,6 +58,19 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
54
58
|
updatedAt TEXT NOT NULL
|
|
55
59
|
);
|
|
56
60
|
|
|
61
|
+
CREATE TABLE IF NOT EXISTS workflows (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
name TEXT NOT NULL,
|
|
64
|
+
description TEXT,
|
|
65
|
+
statuses TEXT NOT NULL,
|
|
66
|
+
transitions TEXT NOT NULL,
|
|
67
|
+
taskTypes TEXT,
|
|
68
|
+
defaultStatusKey TEXT,
|
|
69
|
+
isDefault INTEGER,
|
|
70
|
+
createdAt TEXT NOT NULL,
|
|
71
|
+
updatedAt TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
|
|
57
74
|
CREATE TABLE IF NOT EXISTS teams (
|
|
58
75
|
id TEXT PRIMARY KEY,
|
|
59
76
|
name TEXT NOT NULL,
|
|
@@ -83,6 +100,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
83
100
|
description TEXT,
|
|
84
101
|
status TEXT NOT NULL,
|
|
85
102
|
priority TEXT NOT NULL,
|
|
103
|
+
taskType TEXT,
|
|
86
104
|
assigneeId TEXT,
|
|
87
105
|
reporterId TEXT,
|
|
88
106
|
reviewerId TEXT,
|
|
@@ -183,20 +201,23 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
183
201
|
async createProject(project: Omit<Project, 'id' | 'createdAt' | 'updatedAt'>): Promise<Project> {
|
|
184
202
|
const id = `proj_${Math.random().toString(36).substring(2, 9)}`;
|
|
185
203
|
const now = new Date().toISOString();
|
|
186
|
-
const
|
|
204
|
+
const key = project.key || generateProjectKey(project.name);
|
|
205
|
+
const newProj: Project = { ...project, key, id, createdAt: now, updatedAt: now };
|
|
187
206
|
|
|
188
207
|
const stmt = this.db.prepare(`
|
|
189
|
-
INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, statusDefinitions, priorityDefinitions, customFieldDefinitions, createdAt, updatedAt)
|
|
190
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
208
|
+
INSERT INTO projects (id, key, name, description, ownerId, members, teamIds, workflowId, taskTypes, statusDefinitions, priorityDefinitions, customFieldDefinitions, createdAt, updatedAt)
|
|
209
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
191
210
|
`);
|
|
192
211
|
stmt.run(
|
|
193
212
|
newProj.id,
|
|
194
|
-
newProj.key,
|
|
213
|
+
newProj.key || '',
|
|
195
214
|
newProj.name,
|
|
196
215
|
newProj.description || null,
|
|
197
216
|
newProj.ownerId || null,
|
|
198
217
|
JSON.stringify(newProj.members || []),
|
|
199
218
|
JSON.stringify(newProj.teamIds || []),
|
|
219
|
+
newProj.workflowId || null,
|
|
220
|
+
newProj.taskTypes ? JSON.stringify(newProj.taskTypes) : null,
|
|
200
221
|
JSON.stringify(newProj.statusDefinitions || []),
|
|
201
222
|
JSON.stringify(newProj.priorityDefinitions || []),
|
|
202
223
|
JSON.stringify(newProj.customFieldDefinitions || []),
|
|
@@ -218,16 +239,18 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
218
239
|
|
|
219
240
|
const stmt = this.db.prepare(`
|
|
220
241
|
UPDATE projects
|
|
221
|
-
SET key = ?, name = ?, description = ?, ownerId = ?, members = ?, teamIds = ?, statusDefinitions = ?, priorityDefinitions = ?, customFieldDefinitions = ?, updatedAt = ?
|
|
242
|
+
SET key = ?, name = ?, description = ?, ownerId = ?, members = ?, teamIds = ?, workflowId = ?, taskTypes = ?, statusDefinitions = ?, priorityDefinitions = ?, customFieldDefinitions = ?, updatedAt = ?
|
|
222
243
|
WHERE id = ?
|
|
223
244
|
`);
|
|
224
245
|
stmt.run(
|
|
225
|
-
updated.key,
|
|
246
|
+
updated.key || '',
|
|
226
247
|
updated.name,
|
|
227
248
|
updated.description || null,
|
|
228
249
|
updated.ownerId || null,
|
|
229
250
|
JSON.stringify(updated.members || []),
|
|
230
251
|
JSON.stringify(updated.teamIds || []),
|
|
252
|
+
updated.workflowId || null,
|
|
253
|
+
updated.taskTypes ? JSON.stringify(updated.taskTypes) : null,
|
|
231
254
|
JSON.stringify(updated.statusDefinitions || []),
|
|
232
255
|
JSON.stringify(updated.priorityDefinitions || []),
|
|
233
256
|
JSON.stringify(updated.customFieldDefinitions || []),
|
|
@@ -237,6 +260,77 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
237
260
|
return updated;
|
|
238
261
|
}
|
|
239
262
|
|
|
263
|
+
// --- Workflows ---
|
|
264
|
+
async getWorkflows(): Promise<Workflow[]> {
|
|
265
|
+
const stmt = this.db.prepare('SELECT * FROM workflows');
|
|
266
|
+
const rows = stmt.all() as any[];
|
|
267
|
+
return rows.map((r) => this.mapWorkflow(r));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async getWorkflow(id: string): Promise<Workflow | null> {
|
|
271
|
+
const stmt = this.db.prepare('SELECT * FROM workflows WHERE id = ?');
|
|
272
|
+
const row = stmt.get(id) as any;
|
|
273
|
+
return row ? this.mapWorkflow(row) : null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async createWorkflow(workflow: Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>): Promise<Workflow> {
|
|
277
|
+
const id = `wf_${Math.random().toString(36).substring(2, 9)}`;
|
|
278
|
+
const now = new Date().toISOString();
|
|
279
|
+
const newWf: Workflow = { ...workflow, id, createdAt: now, updatedAt: now };
|
|
280
|
+
|
|
281
|
+
const stmt = this.db.prepare(`
|
|
282
|
+
INSERT INTO workflows (id, name, description, statuses, transitions, taskTypes, defaultStatusKey, isDefault, createdAt, updatedAt)
|
|
283
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
284
|
+
`);
|
|
285
|
+
stmt.run(
|
|
286
|
+
newWf.id,
|
|
287
|
+
newWf.name,
|
|
288
|
+
newWf.description || null,
|
|
289
|
+
JSON.stringify(newWf.statuses || []),
|
|
290
|
+
JSON.stringify(newWf.transitions || []),
|
|
291
|
+
newWf.taskTypes ? JSON.stringify(newWf.taskTypes) : null,
|
|
292
|
+
newWf.defaultStatusKey || null,
|
|
293
|
+
newWf.isDefault ? 1 : 0,
|
|
294
|
+
newWf.createdAt,
|
|
295
|
+
newWf.updatedAt
|
|
296
|
+
);
|
|
297
|
+
return newWf;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async updateWorkflow(id: string, updates: Partial<Workflow>): Promise<Workflow | null> {
|
|
301
|
+
const existing = await this.getWorkflow(id);
|
|
302
|
+
if (!existing) return null;
|
|
303
|
+
|
|
304
|
+
const updated: Workflow = {
|
|
305
|
+
...existing,
|
|
306
|
+
...updates,
|
|
307
|
+
updatedAt: new Date().toISOString()
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const stmt = this.db.prepare(`
|
|
311
|
+
UPDATE workflows SET name = ?, description = ?, statuses = ?, transitions = ?, taskTypes = ?, defaultStatusKey = ?, isDefault = ?, updatedAt = ?
|
|
312
|
+
WHERE id = ?
|
|
313
|
+
`);
|
|
314
|
+
stmt.run(
|
|
315
|
+
updated.name,
|
|
316
|
+
updated.description || null,
|
|
317
|
+
JSON.stringify(updated.statuses || []),
|
|
318
|
+
JSON.stringify(updated.transitions || []),
|
|
319
|
+
updated.taskTypes ? JSON.stringify(updated.taskTypes) : null,
|
|
320
|
+
updated.defaultStatusKey || null,
|
|
321
|
+
updated.isDefault ? 1 : 0,
|
|
322
|
+
updated.updatedAt,
|
|
323
|
+
id
|
|
324
|
+
);
|
|
325
|
+
return updated;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async deleteWorkflow(id: string): Promise<boolean> {
|
|
329
|
+
const stmt = this.db.prepare('DELETE FROM workflows WHERE id = ?');
|
|
330
|
+
const result = stmt.run(id);
|
|
331
|
+
return (result.changes ?? 0) > 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
240
334
|
async deleteProject(id: string): Promise<boolean> {
|
|
241
335
|
const stmt = this.db.prepare('DELETE FROM projects WHERE id = ?');
|
|
242
336
|
const result = stmt.run(id);
|
|
@@ -392,12 +486,12 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
392
486
|
|
|
393
487
|
const stmt = this.db.prepare(`
|
|
394
488
|
INSERT INTO tasks (
|
|
395
|
-
id, projectId, title, description, status, priority, assigneeId, reporterId,
|
|
489
|
+
id, projectId, title, description, status, priority, taskType, assigneeId, reporterId,
|
|
396
490
|
reviewerId, iterationId, teamId, containerId, plannedStartDate, actualStartDate, actualEndDate,
|
|
397
491
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
398
492
|
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes, progress,
|
|
399
493
|
tags, customFields, parentId, createdAt, updatedAt
|
|
400
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
494
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
401
495
|
`);
|
|
402
496
|
stmt.run(
|
|
403
497
|
newTask.id,
|
|
@@ -406,6 +500,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
406
500
|
newTask.description || null,
|
|
407
501
|
newTask.status,
|
|
408
502
|
newTask.priority,
|
|
503
|
+
newTask.taskType || null,
|
|
409
504
|
newTask.assigneeId || null,
|
|
410
505
|
newTask.reporterId || null,
|
|
411
506
|
newTask.reviewerId || null,
|
|
@@ -445,7 +540,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
445
540
|
|
|
446
541
|
const stmt = this.db.prepare(`
|
|
447
542
|
UPDATE tasks SET
|
|
448
|
-
projectId = ?, title = ?, description = ?, status = ?, priority = ?,
|
|
543
|
+
projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
|
|
449
544
|
assigneeId = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?,
|
|
450
545
|
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
451
546
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
@@ -459,6 +554,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
459
554
|
updated.description || null,
|
|
460
555
|
updated.status,
|
|
461
556
|
updated.priority,
|
|
557
|
+
updated.taskType || null,
|
|
462
558
|
updated.assigneeId || null,
|
|
463
559
|
updated.reporterId || null,
|
|
464
560
|
updated.reviewerId || null,
|
|
@@ -704,6 +800,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
704
800
|
private mapProject(row: any): Project {
|
|
705
801
|
return {
|
|
706
802
|
...row,
|
|
803
|
+
workflowId: row.workflowId || undefined,
|
|
804
|
+
taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
|
|
707
805
|
members: row.members ? JSON.parse(row.members) : [],
|
|
708
806
|
teamIds: row.teamIds ? JSON.parse(row.teamIds) : [],
|
|
709
807
|
statusDefinitions: row.statusDefinitions ? JSON.parse(row.statusDefinitions) : [],
|
|
@@ -712,6 +810,16 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
712
810
|
};
|
|
713
811
|
}
|
|
714
812
|
|
|
813
|
+
private mapWorkflow(row: any): Workflow {
|
|
814
|
+
return {
|
|
815
|
+
...row,
|
|
816
|
+
statuses: row.statuses ? JSON.parse(row.statuses) : [],
|
|
817
|
+
transitions: row.transitions ? JSON.parse(row.transitions) : [],
|
|
818
|
+
taskTypes: row.taskTypes ? JSON.parse(row.taskTypes) : undefined,
|
|
819
|
+
isDefault: row.isDefault !== null && row.isDefault !== undefined ? Boolean(row.isDefault) : undefined
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
715
823
|
private mapTeam(row: any): Team {
|
|
716
824
|
return {
|
|
717
825
|
...row,
|
|
@@ -722,6 +830,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
722
830
|
private mapTask(row: any): Task {
|
|
723
831
|
return {
|
|
724
832
|
...row,
|
|
833
|
+
taskType: row.taskType || undefined,
|
|
725
834
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
726
835
|
customFields: row.customFields ? JSON.parse(row.customFields) : {}
|
|
727
836
|
};
|
package/src/types/index.ts
CHANGED
|
@@ -46,14 +46,46 @@ export interface CustomFieldDefinition {
|
|
|
46
46
|
defaultValue?: unknown;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export interface WorkflowTransition {
|
|
50
|
+
id?: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
fromStatusKey: string | '*';
|
|
53
|
+
toStatusKey: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TaskTypeDefinition {
|
|
57
|
+
key: string;
|
|
58
|
+
label: string;
|
|
59
|
+
description?: string;
|
|
60
|
+
icon?: string;
|
|
61
|
+
defaultStatusKey?: string;
|
|
62
|
+
workflowId?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Workflow {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
description?: string;
|
|
69
|
+
statuses: StatusDefinition[];
|
|
70
|
+
transitions: WorkflowTransition[];
|
|
71
|
+
taskTypes?: TaskTypeDefinition[];
|
|
72
|
+
defaultStatusKey?: string;
|
|
73
|
+
isDefault?: boolean;
|
|
74
|
+
createdAt: string;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
export interface Project {
|
|
50
79
|
id: string;
|
|
51
|
-
key
|
|
80
|
+
key?: string; // e.g. "CP" or "PROJ"
|
|
52
81
|
name: string;
|
|
53
82
|
description?: string;
|
|
54
83
|
ownerId?: string;
|
|
55
84
|
members?: string[]; // user IDs
|
|
56
85
|
teamIds?: string[]; // team IDs
|
|
86
|
+
workflowId?: string;
|
|
87
|
+
workflow?: Workflow;
|
|
88
|
+
taskTypes?: TaskTypeDefinition[];
|
|
57
89
|
statusDefinitions?: StatusDefinition[];
|
|
58
90
|
priorityDefinitions?: Array<{ key: string; label: string; level?: number }>;
|
|
59
91
|
customFieldDefinitions?: CustomFieldDefinition[];
|
|
@@ -90,10 +122,12 @@ export interface TaskDependencyGraph {
|
|
|
90
122
|
export interface Task {
|
|
91
123
|
id: string;
|
|
92
124
|
projectId: string;
|
|
125
|
+
key?: string; // e.g. "MOB-12"
|
|
93
126
|
title: string;
|
|
94
127
|
description?: string;
|
|
95
128
|
status: TaskStatus;
|
|
96
129
|
priority: Priority;
|
|
130
|
+
taskType?: string; // e.g. "bug", "feature", "task", "epic"
|
|
97
131
|
assigneeId?: string;
|
|
98
132
|
reporterId?: string;
|
|
99
133
|
reviewerId?: string;
|
|
@@ -183,7 +217,10 @@ export type WebhookEvent =
|
|
|
183
217
|
| 'iteration.started'
|
|
184
218
|
| 'iteration.completed'
|
|
185
219
|
| 'team.created'
|
|
186
|
-
| 'container.created'
|
|
220
|
+
| 'container.created'
|
|
221
|
+
| 'workflow.created'
|
|
222
|
+
| 'workflow.updated'
|
|
223
|
+
| 'workflow.deleted';
|
|
187
224
|
|
|
188
225
|
export interface PluginHooks {
|
|
189
226
|
beforeTaskCreate?: (task: Partial<Task>) => Promise<Partial<Task>> | Partial<Task>;
|
|
@@ -215,5 +252,6 @@ export interface CriticalPathConfig {
|
|
|
215
252
|
iterations?: Iteration[];
|
|
216
253
|
teams?: Team[];
|
|
217
254
|
containers?: TaskContainer[];
|
|
255
|
+
workflows?: Workflow[];
|
|
218
256
|
};
|
|
219
257
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateProjectKey, validateProjectKey, formatTaskKey } from './key.js';
|
|
3
|
+
|
|
4
|
+
describe('Project Key Utilities', () => {
|
|
5
|
+
describe('generateProjectKey', () => {
|
|
6
|
+
it('generates a key for a single word name', () => {
|
|
7
|
+
expect(generateProjectKey('Operative')).toBe('OPE');
|
|
8
|
+
expect(generateProjectKey('App')).toBe('APP');
|
|
9
|
+
expect(generateProjectKey('Go')).toBe('GO');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('generates a key for multi-word names', () => {
|
|
13
|
+
expect(generateProjectKey('Mobile App')).toBe('MA');
|
|
14
|
+
expect(generateProjectKey('Frontend Core Platform')).toBe('FCP');
|
|
15
|
+
expect(generateProjectKey('Very Long Project Name Here')).toBe('VLPN');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('handles special characters and whitespace', () => {
|
|
19
|
+
expect(generateProjectKey(' v2.0 ')).toBe('V20');
|
|
20
|
+
expect(generateProjectKey('Project #1')).toBe('P1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns default PRJ fallback for empty input', () => {
|
|
24
|
+
expect(generateProjectKey('')).toBe('PRJ');
|
|
25
|
+
expect(generateProjectKey(' ')).toBe('PRJ');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('validateProjectKey', () => {
|
|
30
|
+
it('returns true for valid keys', () => {
|
|
31
|
+
expect(validateProjectKey('CP')).toBe(true);
|
|
32
|
+
expect(validateProjectKey('PROJ')).toBe(true);
|
|
33
|
+
expect(validateProjectKey('APP123')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns false for invalid keys', () => {
|
|
37
|
+
expect(validateProjectKey('A')).toBe(false); // too short
|
|
38
|
+
expect(validateProjectKey('TOOLONGKEY')).toBe(false); // too long
|
|
39
|
+
expect(validateProjectKey('proj')).toBe(false); // lowercase
|
|
40
|
+
expect(validateProjectKey('P-1')).toBe(false); // special char
|
|
41
|
+
expect(validateProjectKey('')).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('formatTaskKey', () => {
|
|
46
|
+
it('formats project key and task number', () => {
|
|
47
|
+
expect(formatTaskKey('MOB', 42)).toBe('MOB-42');
|
|
48
|
+
expect(formatTaskKey('op', 1)).toBe('OP-1');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('uses fallback for missing project key', () => {
|
|
52
|
+
expect(formatTaskKey('', 10)).toBe('TASK-10');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/utils/key.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a 2-6 character uppercase project short code/key from a project name.
|
|
3
|
+
* e.g., "Mobile App" -> "MA", "Frontend Core" -> "FC", "Operative" -> "OPE".
|
|
4
|
+
*/
|
|
5
|
+
export function generateProjectKey(name: string): string {
|
|
6
|
+
if (!name || !name.trim()) {
|
|
7
|
+
return 'PRJ';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
11
|
+
let rawKey = '';
|
|
12
|
+
|
|
13
|
+
if (words.length === 1) {
|
|
14
|
+
const word = words[0].replace(/[^a-zA-Z0-9]/g, '');
|
|
15
|
+
if (word.length <= 3) {
|
|
16
|
+
rawKey = word.toUpperCase();
|
|
17
|
+
} else {
|
|
18
|
+
rawKey = word.substring(0, 3).toUpperCase();
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
rawKey = words
|
|
22
|
+
.map((w) => w.replace(/[^a-zA-Z0-9]/g, '')[0] || '')
|
|
23
|
+
.join('')
|
|
24
|
+
.substring(0, 4)
|
|
25
|
+
.toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Ensure key meets basic 2-6 char alphanumeric requirement
|
|
29
|
+
const cleanKey = rawKey.replace(/[^A-Z0-9]/g, '');
|
|
30
|
+
if (cleanKey.length >= 2 && cleanKey.length <= 6) {
|
|
31
|
+
return cleanKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'PRJ';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Validates whether a given key meets project key format rules (2-6 uppercase alphanumeric characters).
|
|
39
|
+
*/
|
|
40
|
+
export function validateProjectKey(key: string): boolean {
|
|
41
|
+
if (!key) return false;
|
|
42
|
+
return /^[A-Z0-9]{2,6}$/.test(key);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats a task key using a project key and task index (e.g. "MOB-12").
|
|
47
|
+
*/
|
|
48
|
+
export function formatTaskKey(projectKey: string, taskNumber: number): string {
|
|
49
|
+
const prefix = projectKey ? projectKey.toUpperCase() : 'TASK';
|
|
50
|
+
return `${prefix}-${taskNumber}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Workflow,
|
|
3
|
+
StatusDefinition,
|
|
4
|
+
TaskTypeDefinition
|
|
5
|
+
} from '../types/index.js';
|
|
6
|
+
import { DEFAULT_STATUS_DEFINITIONS } from './status.js';
|
|
7
|
+
|
|
8
|
+
export class WorkflowValidationError extends Error {
|
|
9
|
+
public readonly fromStatus: string;
|
|
10
|
+
public readonly toStatus: string;
|
|
11
|
+
public readonly workflowId?: string;
|
|
12
|
+
|
|
13
|
+
constructor(fromStatus: string, toStatus: string, workflowId?: string, message?: string) {
|
|
14
|
+
const msg = message || `Illegal status transition from "${fromStatus}" to "${toStatus}"${workflowId ? ` in workflow "${workflowId}"` : ''}.`;
|
|
15
|
+
super(msg);
|
|
16
|
+
this.name = 'WorkflowValidationError';
|
|
17
|
+
this.fromStatus = fromStatus;
|
|
18
|
+
this.toStatus = toStatus;
|
|
19
|
+
this.workflowId = workflowId;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_TASK_TYPES: TaskTypeDefinition[] = [
|
|
24
|
+
{ key: 'task', label: 'Task', description: 'A standard work item', icon: 'check-square' },
|
|
25
|
+
{ key: 'bug', label: 'Bug', description: 'A problem or defect that needs fixing', icon: 'bug' },
|
|
26
|
+
{ key: 'feature', label: 'Feature', description: 'A new capability or enhancement', icon: 'sparkles' },
|
|
27
|
+
{ key: 'epic', label: 'Epic', description: 'A large body of work that spans multiple tasks', icon: 'layers' },
|
|
28
|
+
{ key: 'subtask', label: 'Subtask', description: 'A piece of work required to complete a parent task', icon: 'corner-down-right' }
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_SOFTWARE_WORKFLOW: Workflow = {
|
|
32
|
+
id: 'wf_software_default',
|
|
33
|
+
name: 'Software Development Workflow',
|
|
34
|
+
description: 'Standard software development lifecycle with backlog, review, and completion states.',
|
|
35
|
+
statuses: [
|
|
36
|
+
DEFAULT_STATUS_DEFINITIONS.backlog,
|
|
37
|
+
DEFAULT_STATUS_DEFINITIONS.todo,
|
|
38
|
+
DEFAULT_STATUS_DEFINITIONS.in_progress,
|
|
39
|
+
DEFAULT_STATUS_DEFINITIONS.in_review,
|
|
40
|
+
DEFAULT_STATUS_DEFINITIONS.done,
|
|
41
|
+
DEFAULT_STATUS_DEFINITIONS.canceled
|
|
42
|
+
],
|
|
43
|
+
transitions: [
|
|
44
|
+
{ name: 'Start Work', fromStatusKey: 'backlog', toStatusKey: 'todo' },
|
|
45
|
+
{ name: 'Start Implementation', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
|
|
46
|
+
{ name: 'Complete Task', fromStatusKey: 'todo', toStatusKey: 'done' },
|
|
47
|
+
{ name: 'Submit for Review', fromStatusKey: 'in_progress', toStatusKey: 'in_review' },
|
|
48
|
+
{ name: 'Complete Implementation', fromStatusKey: 'in_progress', toStatusKey: 'done' },
|
|
49
|
+
{ name: 'Approve & Complete', fromStatusKey: 'in_review', toStatusKey: 'done' },
|
|
50
|
+
{ name: 'Request Changes', fromStatusKey: 'in_review', toStatusKey: 'in_progress' },
|
|
51
|
+
{ name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' },
|
|
52
|
+
{ name: 'Cancel', fromStatusKey: '*', toStatusKey: 'canceled' },
|
|
53
|
+
{ name: 'Restore', fromStatusKey: 'canceled', toStatusKey: 'todo' }
|
|
54
|
+
],
|
|
55
|
+
taskTypes: DEFAULT_TASK_TYPES,
|
|
56
|
+
defaultStatusKey: 'todo',
|
|
57
|
+
isDefault: true,
|
|
58
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
59
|
+
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const DEFAULT_SIMPLE_WORKFLOW: Workflow = {
|
|
63
|
+
id: 'wf_simple_default',
|
|
64
|
+
name: 'Simple Task Workflow',
|
|
65
|
+
description: 'Lightweight workflow with To Do, In Progress, and Done states.',
|
|
66
|
+
statuses: [
|
|
67
|
+
DEFAULT_STATUS_DEFINITIONS.todo,
|
|
68
|
+
DEFAULT_STATUS_DEFINITIONS.in_progress,
|
|
69
|
+
DEFAULT_STATUS_DEFINITIONS.done
|
|
70
|
+
],
|
|
71
|
+
transitions: [
|
|
72
|
+
{ name: 'Start', fromStatusKey: 'todo', toStatusKey: 'in_progress' },
|
|
73
|
+
{ name: 'Complete', fromStatusKey: 'todo', toStatusKey: 'done' },
|
|
74
|
+
{ name: 'Complete', fromStatusKey: 'in_progress', toStatusKey: 'done' },
|
|
75
|
+
{ name: 'Reopen', fromStatusKey: 'done', toStatusKey: 'todo' }
|
|
76
|
+
],
|
|
77
|
+
taskTypes: DEFAULT_TASK_TYPES.filter((t) => ['task', 'subtask'].includes(t.key)),
|
|
78
|
+
defaultStatusKey: 'todo',
|
|
79
|
+
isDefault: true,
|
|
80
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
81
|
+
updatedAt: '2026-01-01T00:00:00.000Z'
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function validateTransition(
|
|
85
|
+
workflow: Workflow | undefined,
|
|
86
|
+
fromStatus: string,
|
|
87
|
+
toStatus: string
|
|
88
|
+
): boolean {
|
|
89
|
+
if (fromStatus === toStatus) return true;
|
|
90
|
+
if (!workflow || !workflow.transitions || workflow.transitions.length === 0) return true;
|
|
91
|
+
|
|
92
|
+
return workflow.transitions.some(
|
|
93
|
+
(t) =>
|
|
94
|
+
(t.fromStatusKey === '*' || t.fromStatusKey === fromStatus) &&
|
|
95
|
+
(t.toStatusKey === '*' || t.toStatusKey === toStatus)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getAllowedNextStatuses(
|
|
100
|
+
workflow: Workflow | undefined,
|
|
101
|
+
currentStatus: string
|
|
102
|
+
): string[] {
|
|
103
|
+
if (!workflow || !workflow.statuses || workflow.statuses.length === 0) {
|
|
104
|
+
return Object.keys(DEFAULT_STATUS_DEFINITIONS);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const allStatusKeys = workflow.statuses.map((s) => s.key);
|
|
108
|
+
if (!workflow.transitions || workflow.transitions.length === 0) {
|
|
109
|
+
return allStatusKeys;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const matches = workflow.transitions.filter(
|
|
113
|
+
(t) => t.fromStatusKey === '*' || t.fromStatusKey === currentStatus
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (matches.some((t) => t.toStatusKey === '*')) {
|
|
117
|
+
return allStatusKeys;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const allowed = new Set<string>();
|
|
121
|
+
for (const t of matches) {
|
|
122
|
+
if (allStatusKeys.includes(t.toStatusKey)) {
|
|
123
|
+
allowed.add(t.toStatusKey);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Array.from(allowed);
|
|
128
|
+
}
|