mdboard 1.0.0 → 1.2.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/api.js ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * mdboard — API handlers
3
+ *
4
+ * All REST API route handlers. Receives a context object with
5
+ * model, config, and helper functions.
6
+ *
7
+ * Supports:
8
+ * - Source-scoped routes: /api/sources/:name/tasks, etc.
9
+ * - Overview routes: /api/overview/milestones, etc.
10
+ * - Legacy routes: /api/tasks, etc. (backward compatible)
11
+ * - CRUD: POST to create, DELETE to archive
12
+ */
13
+
14
+ const { URL } = require('url');
15
+
16
+ function jsonResponse(res, data, status = 200) {
17
+ res.writeHead(status, {
18
+ 'Content-Type': 'application/json',
19
+ 'Access-Control-Allow-Origin': '*',
20
+ 'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
21
+ 'Access-Control-Allow-Headers': 'Content-Type',
22
+ });
23
+ res.end(JSON.stringify(data, null, 2));
24
+ }
25
+
26
+ function parseBody(req) {
27
+ return new Promise((resolve) => {
28
+ const chunks = [];
29
+ req.on('data', c => chunks.push(c));
30
+ req.on('end', () => {
31
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
32
+ catch { resolve({}); }
33
+ });
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Build PATCH route map from config.
39
+ */
40
+ function buildPatchMap(config) {
41
+ const map = {
42
+ features: 'tasks', tasks: 'tasks',
43
+ epics: 'epics', milestones: 'milestones', sprints: 'sprints',
44
+ };
45
+ map[config.entities.task.plural.toLowerCase()] = 'tasks';
46
+ map[config.entities.epic.plural.toLowerCase()] = 'epics';
47
+ map[config.entities.milestone.plural.toLowerCase()] = 'milestones';
48
+ map[config.entities.sprint.plural.toLowerCase()] = 'sprints';
49
+ return map;
50
+ }
51
+
52
+ function sourceFields(item) {
53
+ if (!item._source) return {};
54
+ return {
55
+ source: item._source,
56
+ sourceLabel: item._sourceLabel || item._source,
57
+ sourceColor: item._sourceColor || null,
58
+ readonly: item._readonly || false,
59
+ author: item._author || null,
60
+ };
61
+ }
62
+
63
+ function formatTask(f) {
64
+ return {
65
+ id: f.id,
66
+ title: f.title,
67
+ epic: f._epic || f.epic,
68
+ milestone: f._milestone || f.milestone,
69
+ sprint: f.sprint,
70
+ status: f.status,
71
+ priority: f.priority,
72
+ points: f.points,
73
+ assigned: f.assigned,
74
+ branches: f.branches,
75
+ pull_requests: f.pull_requests,
76
+ links: f.links || null,
77
+ created: f.created,
78
+ started: f.started,
79
+ completed: f.completed,
80
+ content: f.content,
81
+ ...sourceFields(f),
82
+ };
83
+ }
84
+
85
+ function formatMilestone(ms) {
86
+ return {
87
+ id: ms.id,
88
+ title: ms.title,
89
+ status: ms.status,
90
+ deadline: ms.deadline,
91
+ tracks: ms.tracks || null,
92
+ progress: ms._progress || 0,
93
+ featureCount: ms._featureCount || 0,
94
+ completedCount: ms._completedCount || 0,
95
+ created: ms.created,
96
+ content: ms.content,
97
+ ...sourceFields(ms),
98
+ };
99
+ }
100
+
101
+ function formatEpic(e) {
102
+ return {
103
+ id: e.id,
104
+ title: e.title,
105
+ milestone: e._milestone,
106
+ status: e.status,
107
+ priority: e.priority,
108
+ dependencies: e.dependencies,
109
+ featureCount: e._featureCount || 0,
110
+ completedCount: e._completedCount || 0,
111
+ totalPoints: e._totalPoints || 0,
112
+ progress: e._progress || 0,
113
+ content: e.content,
114
+ ...sourceFields(e),
115
+ };
116
+ }
117
+
118
+ function formatSprint(s) {
119
+ return {
120
+ id: s.id,
121
+ milestone: s._milestone,
122
+ status: s.status,
123
+ goal: s.goal,
124
+ start_date: s.start_date,
125
+ end_date: s.end_date,
126
+ planned_points: s.planned_points,
127
+ completed_points: s.completed_points,
128
+ features: s.features,
129
+ ...sourceFields(s),
130
+ };
131
+ }
132
+
133
+ function filterTasks(tasks, url) {
134
+ const status = url.searchParams.get('status');
135
+ const epic = url.searchParams.get('epic');
136
+ const milestone = url.searchParams.get('milestone');
137
+ const sprint = url.searchParams.get('sprint');
138
+ const source = url.searchParams.get('source');
139
+
140
+ if (status) tasks = tasks.filter(f => f.status === status);
141
+ if (epic) tasks = tasks.filter(f => f.epic === epic);
142
+ if (milestone) tasks = tasks.filter(f => f.milestone === milestone);
143
+ if (sprint) tasks = tasks.filter(f => f.sprint === sprint);
144
+ if (source) tasks = tasks.filter(f => f.source === source);
145
+
146
+ return tasks;
147
+ }
148
+
149
+ function computeHealth(model, config) {
150
+ const completedStatus = config.completedStatus;
151
+ const inProgressStatus = (config.statuses.task.find(s => s.icon === 'half-circle') || {}).key || 'in-progress';
152
+
153
+ const activeMilestone = model.milestones.find(m => m.status === 'active');
154
+ const activeSprint = model.sprints.find(s => s.status === 'active');
155
+ const totalFeatures = model.tasks.length;
156
+ const completedFeatures = model.tasks.filter(f => f.status === completedStatus).length;
157
+ const inProgressFeatures = model.tasks.filter(f => f.status === inProgressStatus).length;
158
+
159
+ const completedSprints = model.sprints.filter(s => s.status === 'completed');
160
+ let velocity = null;
161
+ if (completedSprints.length > 0) {
162
+ const totalVelocity = completedSprints.reduce((sum, s) => {
163
+ const planned = s.planned_points || 1;
164
+ const completed = s.completed_points || 0;
165
+ return sum + Math.round((completed / planned) * 100);
166
+ }, 0);
167
+ velocity = Math.round(totalVelocity / completedSprints.length);
168
+ }
169
+
170
+ return {
171
+ status: 'ok',
172
+ activeMilestone: activeMilestone ? activeMilestone.id : null,
173
+ activeSprint: activeSprint ? activeSprint.id : null,
174
+ totalFeatures,
175
+ completedFeatures,
176
+ inProgressFeatures,
177
+ velocity,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Get items filtered by source name from the global model.
183
+ */
184
+ function itemsBySource(model, sourceName) {
185
+ const filter = (arr) => arr.filter(x => x._source === sourceName);
186
+ return {
187
+ project: model.project && model.project._source === sourceName ? model.project : null,
188
+ milestones: filter(model.milestones),
189
+ epics: filter(model.epics),
190
+ tasks: filter(model.tasks),
191
+ sprints: filter(model.sprints),
192
+ boards: filter(model.boards),
193
+ reviews: filter(model.reviews),
194
+ metrics: model.metrics && model.metrics._source === sourceName ? model.metrics : null,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Handle all API requests.
200
+ *
201
+ * @param {object} req - HTTP request
202
+ * @param {object} res - HTTP response
203
+ * @param {object} ctx - Context object:
204
+ * model, config, port, projectDir, broadcast, sources, sourceStates,
205
+ * updateFile(sourceName, relFile, updates), rescanAll(), syncRemotes(),
206
+ * createItem(sourceName, collection, data), archiveItem(sourceName, collection, id)
207
+ */
208
+ async function handleApi(req, res, ctx) {
209
+ const { model, config, port, sources } = ctx;
210
+ const url = new URL(req.url, `http://localhost:${port}`);
211
+ const pathname = url.pathname;
212
+
213
+ // CORS preflight
214
+ if (req.method === 'OPTIONS') {
215
+ res.writeHead(204, {
216
+ 'Access-Control-Allow-Origin': '*',
217
+ 'Access-Control-Allow-Methods': 'GET, PATCH, POST, DELETE, OPTIONS',
218
+ 'Access-Control-Allow-Headers': 'Content-Type',
219
+ });
220
+ return res.end();
221
+ }
222
+
223
+ // ─── Source-scoped routes: /api/sources/:name/... ───────────────────
224
+ const sourceMatch = pathname.match(/^\/api\/sources\/([^/]+)\/(.+)$/);
225
+ if (sourceMatch) {
226
+ const sourceName = decodeURIComponent(sourceMatch[1]);
227
+ const subPath = sourceMatch[2];
228
+ return handleSourceRoute(req, res, ctx, url, sourceName, subPath);
229
+ }
230
+
231
+ // ─── Overview routes: /api/overview/... ─────────────────────────────
232
+ if (pathname.startsWith('/api/overview/')) {
233
+ return handleOverviewRoute(req, res, ctx, url, pathname);
234
+ }
235
+
236
+ // ─── POST routes ────────────────────────────────────────────────────
237
+ if (req.method === 'POST') {
238
+ if (pathname === '/api/sources/sync') {
239
+ if (ctx.syncRemotes) {
240
+ try {
241
+ await ctx.syncRemotes();
242
+ return jsonResponse(res, { ok: true, message: 'Sync complete' });
243
+ } catch (err) {
244
+ return jsonResponse(res, { error: err.message }, 500);
245
+ }
246
+ }
247
+ return jsonResponse(res, { ok: true, message: 'No remote sources' });
248
+ }
249
+
250
+ // Legacy CRUD: POST /api/tasks, /api/milestones, etc.
251
+ const postMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
252
+ const postMatch = pathname.match(/^\/api\/([\w-]+)$/);
253
+ if (postMatch && postMap[postMatch[1]]) {
254
+ return handleLegacyCreate(req, res, ctx, postMap[postMatch[1]]);
255
+ }
256
+
257
+ return jsonResponse(res, { error: 'Not found' }, 404);
258
+ }
259
+
260
+ // ─── DELETE routes (legacy): DELETE /api/:collection/:id ────────────
261
+ if (req.method === 'DELETE') {
262
+ const deleteMatch = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
263
+ if (deleteMatch) {
264
+ const patchMap = buildPatchMap(config);
265
+ const collection = patchMap[deleteMatch[1]];
266
+ if (collection) {
267
+ return handleDelete(req, res, ctx, collection, decodeURIComponent(deleteMatch[2]));
268
+ }
269
+ }
270
+ return jsonResponse(res, { error: 'Not found' }, 404);
271
+ }
272
+
273
+ // ─── PATCH routes (legacy) ──────────────────────────────────────────
274
+ if (req.method === 'PATCH') {
275
+ const match = pathname.match(/^\/api\/([\w-]+)\/(.+)$/);
276
+ if (match) {
277
+ const patchMap = buildPatchMap(config);
278
+ const collection = patchMap[match[1]];
279
+ if (collection) {
280
+ return handlePatch(req, res, collection, decodeURIComponent(match[2]), ctx);
281
+ }
282
+ }
283
+ return jsonResponse(res, { error: 'Not found' }, 404);
284
+ }
285
+
286
+ // ─── GET routes (legacy + global) ──────────────────────────────────
287
+ switch (pathname) {
288
+ case '/api/config': {
289
+ const result = {
290
+ entities: config.entities,
291
+ statuses: config.statuses,
292
+ priorities: config.priorities,
293
+ boardColumns: config.boardColumns,
294
+ completedStatus: config.completedStatus,
295
+ };
296
+ if (sources && sources.length > 0) {
297
+ result.workspace = {
298
+ sources: sources.map(s => ({
299
+ name: s.name, label: s.label || s.name,
300
+ color: s.color || null, icon: s.icon || s.name.charAt(0).toUpperCase(),
301
+ type: s.type || 'local',
302
+ readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
303
+ })),
304
+ hasMultipleSources: sources.length > 1,
305
+ };
306
+ }
307
+ return jsonResponse(res, result);
308
+ }
309
+
310
+ case '/api/project':
311
+ return jsonResponse(res, model.project || {});
312
+
313
+ case '/api/milestones':
314
+ return jsonResponse(res, model.milestones.map(formatMilestone));
315
+
316
+ case '/api/epics':
317
+ return jsonResponse(res, model.epics.map(formatEpic));
318
+
319
+ case '/api/tasks':
320
+ case '/api/features':
321
+ return jsonResponse(res, filterTasks(model.tasks.map(formatTask), url));
322
+
323
+ case '/api/sprints':
324
+ return jsonResponse(res, model.sprints.map(formatSprint));
325
+
326
+ case '/api/sprint': {
327
+ const activeSprint = model.sprints.find(s => s.status === 'active');
328
+ if (!activeSprint) return jsonResponse(res, null);
329
+
330
+ const board = model.boards.find(b =>
331
+ b._dir === activeSprint._dir && b._milestone === activeSprint._milestone &&
332
+ (!activeSprint._source || b._source === activeSprint._source)
333
+ );
334
+ return jsonResponse(res, {
335
+ ...formatSprint(activeSprint),
336
+ board: board ? board.content : null,
337
+ });
338
+ }
339
+
340
+ case '/api/metrics':
341
+ return jsonResponse(res, model.metrics || {});
342
+
343
+ case '/api/health':
344
+ return jsonResponse(res, computeHealth(model, config));
345
+
346
+ case '/api/sources': {
347
+ if (!sources || sources.length === 0) return jsonResponse(res, []);
348
+
349
+ return jsonResponse(res, sources.map(s => {
350
+ const sourceTasks = model.tasks.filter(t => t._source === s.name);
351
+ const completedStatus = config.completedStatus;
352
+ return {
353
+ name: s.name,
354
+ label: s.label || s.name,
355
+ color: s.color || null,
356
+ icon: s.icon || s.name.charAt(0).toUpperCase(),
357
+ type: s.type || 'local',
358
+ readonly: s.readonly != null ? s.readonly : (s.type === 'remote'),
359
+ taskCount: sourceTasks.length,
360
+ completedCount: sourceTasks.filter(t => t.status === completedStatus).length,
361
+ lastSync: s._lastSync || null,
362
+ error: s._error || null,
363
+ };
364
+ }));
365
+ }
366
+
367
+ case '/api/events': {
368
+ res.writeHead(200, {
369
+ 'Content-Type': 'text/event-stream',
370
+ 'Cache-Control': 'no-cache',
371
+ 'Connection': 'keep-alive',
372
+ 'Access-Control-Allow-Origin': '*',
373
+ });
374
+ res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
375
+
376
+ if (ctx.sseClients) {
377
+ ctx.sseClients.add(res);
378
+ }
379
+
380
+ const keepalive = setInterval(() => {
381
+ try { res.write(': keepalive\n\n'); } catch { /* client gone */ }
382
+ }, 30000);
383
+
384
+ req.on('close', () => {
385
+ if (ctx.sseClients) ctx.sseClients.delete(res);
386
+ clearInterval(keepalive);
387
+ });
388
+ return;
389
+ }
390
+
391
+ default:
392
+ return jsonResponse(res, { error: 'Not found' }, 404);
393
+ }
394
+ }
395
+
396
+ // ═══════════════════════════════════════════════════════════════════════
397
+ // Source-scoped routes
398
+ // ═══════════════════════════════════════════════════════════════════════
399
+
400
+ async function handleSourceRoute(req, res, ctx, url, sourceName, subPath) {
401
+ const { model, config, sources } = ctx;
402
+
403
+ // Validate source
404
+ const source = sources ? sources.find(s => s.name === sourceName) : null;
405
+ if (!source) {
406
+ return jsonResponse(res, { error: 'Source not found: ' + sourceName }, 404);
407
+ }
408
+
409
+ const sourceModel = itemsBySource(model, sourceName);
410
+ const isReadonly = source.readonly != null ? source.readonly : (source.type === 'remote');
411
+
412
+ // ── GET routes ──
413
+ if (req.method === 'GET') {
414
+ switch (subPath) {
415
+ case 'config': {
416
+ // Source-specific config (from sourceStates if available)
417
+ const sourceState = ctx.sourceStates ? ctx.sourceStates.get(sourceName) : null;
418
+ const srcConfig = sourceState ? sourceState.config : config;
419
+ return jsonResponse(res, {
420
+ entities: srcConfig.entities,
421
+ statuses: srcConfig.statuses,
422
+ priorities: srcConfig.priorities,
423
+ boardColumns: srcConfig.boardColumns,
424
+ completedStatus: srcConfig.completedStatus,
425
+ source: { name: source.name, label: source.label, color: source.color, readonly: isReadonly },
426
+ });
427
+ }
428
+
429
+ case 'project':
430
+ return jsonResponse(res, sourceModel.project || {});
431
+
432
+ case 'milestones':
433
+ return jsonResponse(res, sourceModel.milestones.map(formatMilestone));
434
+
435
+ case 'epics':
436
+ return jsonResponse(res, sourceModel.epics.map(formatEpic));
437
+
438
+ case 'tasks':
439
+ return jsonResponse(res, filterTasks(sourceModel.tasks.map(formatTask), url));
440
+
441
+ case 'sprints':
442
+ return jsonResponse(res, sourceModel.sprints.map(formatSprint));
443
+
444
+ case 'sprint': {
445
+ const activeSprint = sourceModel.sprints.find(s => s.status === 'active');
446
+ if (!activeSprint) return jsonResponse(res, null);
447
+ const board = sourceModel.boards.find(b =>
448
+ b._dir === activeSprint._dir && b._milestone === activeSprint._milestone
449
+ );
450
+ return jsonResponse(res, {
451
+ ...formatSprint(activeSprint),
452
+ board: board ? board.content : null,
453
+ });
454
+ }
455
+
456
+ case 'health':
457
+ return jsonResponse(res, computeHealth(sourceModel, config));
458
+
459
+ case 'metrics':
460
+ return jsonResponse(res, sourceModel.metrics || {});
461
+
462
+ default:
463
+ return jsonResponse(res, { error: 'Not found' }, 404);
464
+ }
465
+ }
466
+
467
+ // ── POST routes (create) ──
468
+ if (req.method === 'POST') {
469
+ if (isReadonly) {
470
+ return jsonResponse(res, { error: 'Source is read-only' }, 403);
471
+ }
472
+
473
+ const entityMap = { tasks: 'tasks', milestones: 'milestones', epics: 'epics', sprints: 'sprints' };
474
+ if (entityMap[subPath] && ctx.createItem) {
475
+ const body = await parseBody(req);
476
+ try {
477
+ const result = ctx.createItem(sourceName, entityMap[subPath], body);
478
+ if (ctx.rescanAll) ctx.rescanAll();
479
+ if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
480
+ return jsonResponse(res, { ok: true, ...result }, 201);
481
+ } catch (err) {
482
+ return jsonResponse(res, { error: err.message }, 400);
483
+ }
484
+ }
485
+
486
+ return jsonResponse(res, { error: 'Not found' }, 404);
487
+ }
488
+
489
+ // ── PATCH routes: /api/sources/:name/:entity/:id ──
490
+ if (req.method === 'PATCH') {
491
+ const patchMatch = subPath.match(/^([\w-]+)\/(.+)$/);
492
+ if (patchMatch) {
493
+ const patchMap = buildPatchMap(config);
494
+ const collection = patchMap[patchMatch[1]];
495
+ if (collection) {
496
+ const id = decodeURIComponent(patchMatch[2]);
497
+ return handlePatch(req, res, collection, id, ctx, sourceName);
498
+ }
499
+ }
500
+ return jsonResponse(res, { error: 'Not found' }, 404);
501
+ }
502
+
503
+ // ── DELETE routes: /api/sources/:name/:entity/:id ──
504
+ if (req.method === 'DELETE') {
505
+ if (isReadonly) {
506
+ return jsonResponse(res, { error: 'Source is read-only' }, 403);
507
+ }
508
+
509
+ const deleteMatch = subPath.match(/^([\w-]+)\/(.+)$/);
510
+ if (deleteMatch) {
511
+ const patchMap = buildPatchMap(config);
512
+ const collection = patchMap[deleteMatch[1]];
513
+ if (collection) {
514
+ const id = decodeURIComponent(deleteMatch[2]);
515
+ return handleDelete(req, res, ctx, collection, id, sourceName);
516
+ }
517
+ }
518
+ return jsonResponse(res, { error: 'Not found' }, 404);
519
+ }
520
+
521
+ return jsonResponse(res, { error: 'Method not allowed' }, 405);
522
+ }
523
+
524
+ // ═══════════════════════════════════════════════════════════════════════
525
+ // Overview routes
526
+ // ═══════════════════════════════════════════════════════════════════════
527
+
528
+ function handleOverviewRoute(req, res, ctx, url, pathname) {
529
+ const { model, config } = ctx;
530
+
531
+ if (req.method !== 'GET') {
532
+ return jsonResponse(res, { error: 'Method not allowed' }, 405);
533
+ }
534
+
535
+ switch (pathname) {
536
+ case '/api/overview/milestones': {
537
+ // Global milestones with tracked sub-milestone progress
538
+ const globalMs = model.milestones.map(ms => {
539
+ const formatted = formatMilestone(ms);
540
+
541
+ // If this milestone has `tracks`, compute combined progress
542
+ if (ms.tracks && Array.isArray(ms.tracks)) {
543
+ const tracked = [];
544
+ for (const ref of ms.tracks) {
545
+ const parts = String(ref).split(':');
546
+ if (parts.length === 2) {
547
+ const [srcName, msId] = parts;
548
+ const sub = model.milestones.find(m =>
549
+ (m._source === srcName) && (m.id === ref || m._originalId === msId || m.id === msId)
550
+ );
551
+ if (sub) {
552
+ tracked.push({
553
+ ref,
554
+ source: sub._source,
555
+ sourceColor: sub._sourceColor,
556
+ id: sub.id,
557
+ title: sub.title,
558
+ progress: sub._progress || 0,
559
+ featureCount: sub._featureCount || 0,
560
+ completedCount: sub._completedCount || 0,
561
+ });
562
+ }
563
+ }
564
+ }
565
+ formatted.tracked = tracked;
566
+ if (tracked.length > 0) {
567
+ const totalFeatures = tracked.reduce((s, t) => s + t.featureCount, 0);
568
+ const totalCompleted = tracked.reduce((s, t) => s + t.completedCount, 0);
569
+ formatted.combinedProgress = totalFeatures > 0 ? Math.round((totalCompleted / totalFeatures) * 100) : 0;
570
+ }
571
+ }
572
+ return formatted;
573
+ });
574
+ return jsonResponse(res, globalMs);
575
+ }
576
+
577
+ case '/api/overview/links': {
578
+ // Collect all cross-project links
579
+ const links = [];
580
+ for (const task of model.tasks) {
581
+ if (task.links && Array.isArray(task.links)) {
582
+ for (const link of task.links) {
583
+ links.push({
584
+ from: task.id,
585
+ fromSource: task._source || null,
586
+ fromSourceColor: task._sourceColor || null,
587
+ to: link,
588
+ toSource: String(link).split(':')[0] || null,
589
+ });
590
+ }
591
+ }
592
+ }
593
+
594
+ // Compute reverse links
595
+ const reverseMap = {};
596
+ for (const link of links) {
597
+ if (!reverseMap[link.to]) reverseMap[link.to] = [];
598
+ reverseMap[link.to].push({ from: link.from, source: link.fromSource, sourceColor: link.fromSourceColor });
599
+ }
600
+
601
+ return jsonResponse(res, { links, reverseLinks: reverseMap });
602
+ }
603
+
604
+ case '/api/overview/metrics': {
605
+ // Aggregated metrics across all sources
606
+ const completedStatus = config.completedStatus;
607
+ const sourceMetrics = {};
608
+
609
+ if (ctx.sources) {
610
+ for (const s of ctx.sources) {
611
+ const tasks = model.tasks.filter(t => t._source === s.name);
612
+ sourceMetrics[s.name] = {
613
+ label: s.label || s.name,
614
+ color: s.color || null,
615
+ totalTasks: tasks.length,
616
+ completedTasks: tasks.filter(t => t.status === completedStatus).length,
617
+ totalPoints: tasks.reduce((sum, t) => sum + (t.points || 0), 0),
618
+ };
619
+ }
620
+ }
621
+
622
+ return jsonResponse(res, {
623
+ totalTasks: model.tasks.length,
624
+ completedTasks: model.tasks.filter(t => t.status === completedStatus).length,
625
+ totalMilestones: model.milestones.length,
626
+ totalEpics: model.epics.length,
627
+ sources: sourceMetrics,
628
+ });
629
+ }
630
+
631
+ default:
632
+ return jsonResponse(res, { error: 'Not found' }, 404);
633
+ }
634
+ }
635
+
636
+ // ═══════════════════════════════════════════════════════════════════════
637
+ // CRUD helpers
638
+ // ═══════════════════════════════════════════════════════════════════════
639
+
640
+ async function handlePatch(req, res, collection, id, ctx, sourceName) {
641
+ const { model, config } = ctx;
642
+ const body = await parseBody(req);
643
+
644
+ // Resolve item — try exact id first, then try stripping source prefix
645
+ let item;
646
+ const findIn = (arr) => {
647
+ let found = arr.find(x => x.id === id);
648
+ if (!found) found = arr.find(x => x._originalId === id);
649
+ // If sourceName is provided, further filter
650
+ if (found && sourceName && found._source && found._source !== sourceName) found = null;
651
+ return found;
652
+ };
653
+
654
+ switch (collection) {
655
+ case 'tasks': item = findIn(model.tasks); break;
656
+ case 'epics': item = findIn(model.epics); break;
657
+ case 'milestones': item = findIn(model.milestones); break;
658
+ case 'sprints': item = findIn(model.sprints); break;
659
+ }
660
+
661
+ if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
662
+
663
+ // Readonly guard
664
+ if (item._readonly) {
665
+ return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
666
+ }
667
+
668
+ try {
669
+ if (ctx.updateFile) {
670
+ ctx.updateFile(item._source || null, item._file, body);
671
+ }
672
+ if (ctx.rescanAll) ctx.rescanAll();
673
+ if (ctx.broadcast) {
674
+ ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
675
+ }
676
+ return jsonResponse(res, { ok: true });
677
+ } catch (err) {
678
+ return jsonResponse(res, { error: err.message }, 500);
679
+ }
680
+ }
681
+
682
+ async function handleDelete(req, res, ctx, collection, id, sourceName) {
683
+ const { model } = ctx;
684
+
685
+ let item;
686
+ const findIn = (arr) => {
687
+ let found = arr.find(x => x.id === id);
688
+ if (!found) found = arr.find(x => x._originalId === id);
689
+ if (found && sourceName && found._source && found._source !== sourceName) found = null;
690
+ return found;
691
+ };
692
+
693
+ switch (collection) {
694
+ case 'tasks': item = findIn(model.tasks); break;
695
+ case 'epics': item = findIn(model.epics); break;
696
+ case 'milestones': item = findIn(model.milestones); break;
697
+ case 'sprints': item = findIn(model.sprints); break;
698
+ }
699
+
700
+ if (!item) return jsonResponse(res, { error: collection.slice(0, -1) + ' not found: ' + id }, 404);
701
+
702
+ if (item._readonly) {
703
+ return jsonResponse(res, { error: 'This item is read-only (remote source)' }, 403);
704
+ }
705
+
706
+ try {
707
+ if (ctx.archiveItem) {
708
+ ctx.archiveItem(item._source || null, item);
709
+ }
710
+ if (ctx.rescanAll) ctx.rescanAll();
711
+ if (ctx.broadcast) {
712
+ ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
713
+ }
714
+ return jsonResponse(res, { ok: true, archived: true });
715
+ } catch (err) {
716
+ return jsonResponse(res, { error: err.message }, 500);
717
+ }
718
+ }
719
+
720
+ async function handleLegacyCreate(req, res, ctx, collection) {
721
+ const body = await parseBody(req);
722
+
723
+ // Resolve source: explicit _source from body, or legacy (no workspace) mode
724
+ let sourceName = null;
725
+ if (ctx.sources && ctx.sources.length > 0) {
726
+ if (body._source) {
727
+ sourceName = body._source;
728
+ } else {
729
+ // Prefer non-overview, non-readonly writable source
730
+ const writable = ctx.sources.filter(s =>
731
+ s.name !== 'overview' && !(s.readonly != null ? s.readonly : (s.type === 'remote'))
732
+ );
733
+ sourceName = writable.length > 0 ? writable[0].name
734
+ : (ctx.sources.find(s => !(s.readonly != null ? s.readonly : (s.type === 'remote')))?.name || null);
735
+ }
736
+ }
737
+
738
+ if (!ctx.createItem) {
739
+ return jsonResponse(res, { error: 'Create not supported' }, 400);
740
+ }
741
+
742
+ try {
743
+ const result = ctx.createItem(sourceName, collection, body);
744
+ if (ctx.rescanAll) ctx.rescanAll();
745
+ if (ctx.broadcast) ctx.broadcast({ type: 'update', timestamp: new Date().toISOString() });
746
+ return jsonResponse(res, { ok: true, ...result }, 201);
747
+ } catch (err) {
748
+ return jsonResponse(res, { error: err.message }, 400);
749
+ }
750
+ }
751
+
752
+ module.exports = { handleApi, jsonResponse, buildPatchMap };