local-kanban 1.6.3

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 (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,477 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { writeFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
3
+ import { join, dirname } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { db, DATA_DIR, STATUSES, WORK_SEGMENT_MAX_S, enqueue, nextTaskNo, nextPosition, logError } from '../db.js';
6
+ import { scalarQuery } from '../query-params.js';
7
+ import { emit } from '../bus.js';
8
+ import { resolveProject } from './projects.js';
9
+ import { kick } from '../sync/worker.js';
10
+ import { LABELS } from '../sync/github.js';
11
+ import { repoHttpsBase } from '../repo-base.js';
12
+ import { listShape } from '../task-shape.js';
13
+
14
+ const ATTACH_DIR = join(DATA_DIR, 'attachments');
15
+ const ATTACH_MAX = 30;
16
+ const MIME_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
17
+ function taskAttachments(id) {
18
+ return db.prepare("SELECT id, file, mime, created_at FROM task_attachments WHERE task_id = ? AND file != '' ORDER BY id").all(id)
19
+ .map((a) => ({ ...a, url: `/attachments/${id}/${a.file}`, path: join(ATTACH_DIR, String(id), a.file) }));
20
+ }
21
+
22
+ function taskChecklist(id) {
23
+ return db.prepare('SELECT id, text, done, position FROM task_checklist WHERE task_id = ? ORDER BY position, id').all(id)
24
+ .map((c) => ({ ...c, done: !!c.done }));
25
+ }
26
+
27
+ async function commitUrlFor(task) {
28
+ const c = db.prepare("SELECT body FROM task_comments WHERE task_id = ? AND author = 'git' ORDER BY id DESC LIMIT 1").get(task.id);
29
+ const hash = (c?.body || '').trim().split(/\s/)[0];
30
+ if (!/^[0-9a-f]{7,40}$/i.test(hash)) return null;
31
+ const proj = db.prepare('SELECT path FROM projects WHERE id = ?').get(task.project_id);
32
+ const base = await repoHttpsBase(proj?.path);
33
+ return base ? `${base}/commit/${hash}` : null;
34
+ }
35
+
36
+ function attachGitStat(task, startedAt) {
37
+ const project = db.prepare('SELECT path FROM projects WHERE id = ?').get(task.project_id);
38
+ if (!project?.path) return;
39
+ const issueNo = task.gh_issue_number;
40
+ let args, gate = null;
41
+ if (issueNo) {
42
+ args = ['-C', project.path, 'log', '-1', '-E', `--grep=#${issueNo}([^0-9]|$)`, '--format=%h|%ct|%s', '--shortstat'];
43
+ } else if (startedAt) {
44
+ args = ['-C', project.path, 'log', '-1', '--format=%h|%ct|%s', '--shortstat'];
45
+ gate = startedAt;
46
+ } else {
47
+ return;
48
+ }
49
+ execFile('git', args, { timeout: 5000 }, (err, stdout) => {
50
+ try {
51
+ if (err) return;
52
+ const lines = stdout.trim().split('\n').filter(Boolean);
53
+ if (!lines.length) return;
54
+ const [hash, ct, ...subj] = (lines[0] || '').split('|');
55
+ if (!hash || !ct) return;
56
+ if (gate && Number(ct) < Math.floor(new Date(gate + 'Z').getTime() / 1000)) return;
57
+ if (db.prepare("SELECT 1 FROM task_comments WHERE task_id = ? AND author = 'git' AND body LIKE ?").get(task.id, hash + ' %')) return;
58
+ const stat = lines[1] ? lines[1].trim() : '';
59
+ const body = `${hash} ${subj.join('|')}${stat ? `\n${stat}` : ''}`;
60
+ const r = db.prepare("INSERT INTO task_comments (task_id, author, body) VALUES (?, 'git', ?)").run(task.id, body);
61
+ const c = db.prepare('SELECT * FROM task_comments WHERE id = ?').get(r.lastInsertRowid);
62
+ emit('task.comment', { task_id: task.id, key: task.key, project: task.project, comment: c });
63
+ } catch (e) {
64
+ logError('server', 'attachGitStat', e?.message || String(e), `task #${task.id}`);
65
+ }
66
+ });
67
+ }
68
+
69
+ export const TASK_SELECT = `
70
+ SELECT t.*, p.slug AS project, (p.prefix || '-' || t.task_no) AS key
71
+ FROM tasks t JOIN projects p ON p.id = t.project_id
72
+ `;
73
+
74
+ export function resolveTask(idOrKey) {
75
+ if (/^\d+$/.test(idOrKey)) return db.prepare(`${TASK_SELECT} WHERE t.id = ?`).get(idOrKey);
76
+ const m = String(idOrKey).match(/^([a-z0-9]+)-(\d+)$/i);
77
+ if (!m) return null;
78
+ return db.prepare(`${TASK_SELECT} WHERE upper(p.prefix) = upper(?) AND t.task_no = ?`).get(m[1], m[2]);
79
+ }
80
+
81
+ export function snapshot(t) {
82
+ return {
83
+ task_id: t.id, project: t.project, key: t.key, title: t.title, description: t.description,
84
+ status: t.status, priority: t.priority, blocked: t.blocked, labels: JSON.parse(t.labels || '[]'),
85
+ gh_issue_number: t.gh_issue_number, gh_item_id: t.gh_item_id,
86
+ };
87
+ }
88
+
89
+ function taskLinks(id) {
90
+ return db.prepare(`
91
+ SELECT t.id, (p.prefix || '-' || t.task_no) AS key, t.title, t.status,
92
+ CASE WHEN l.kind = 'parent' AND l.task_id = @id THEN 'child'
93
+ WHEN l.kind = 'parent' AND l.linked_task_id = @id THEN 'parent'
94
+ ELSE 'related' END AS rel
95
+ FROM task_links l
96
+ JOIN tasks t ON t.id = CASE WHEN l.task_id = @id THEN l.linked_task_id ELSE l.task_id END
97
+ JOIN projects p ON p.id = t.project_id
98
+ WHERE l.task_id = @id OR l.linked_task_id = @id
99
+ ORDER BY rel, t.id
100
+ `).all({ id });
101
+ }
102
+
103
+ function removeLinkPair(a, b) {
104
+ db.prepare('DELETE FROM task_links WHERE (task_id = ? AND linked_task_id = ?) OR (task_id = ? AND linked_task_id = ?)')
105
+ .run(a, b, b, a);
106
+ }
107
+
108
+ function placeAdjacent(a, b) {
109
+ if (a.status !== b.status || a.project_id !== b.project_id) return;
110
+ const next = db.prepare('SELECT MIN(position) AS p FROM tasks WHERE status=? AND project_id=? AND position > ? AND id != ?')
111
+ .get(a.status, a.project_id, a.position, b.id);
112
+ const newPos = next && next.p != null ? (a.position + next.p) / 2 : a.position + 1;
113
+ db.prepare("UPDATE tasks SET position = ?, updated_at = datetime('now') WHERE id = ?").run(newPos, b.id);
114
+ }
115
+
116
+ function normalizeLabels(v) {
117
+ if (!Array.isArray(v)) return null;
118
+ return v.filter((l) => typeof l === 'string' && l.trim()).map((l) => l.trim());
119
+ }
120
+
121
+ function unknownLabels(arr) {
122
+ return arr.filter((l) => !Object.prototype.hasOwnProperty.call(LABELS, l));
123
+ }
124
+ function labelPaletteError(bad) {
125
+ return { error: `unknown labels: ${bad.join(', ')} — they are not in the palette and the GitHub sync would fail. Allowed: ${Object.keys(LABELS).join(', ')}` };
126
+ }
127
+
128
+ export default async function taskRoutes(app) {
129
+ app.addContentTypeParser(/^image\//, { parseAs: 'buffer', bodyLimit: 6 * 1024 * 1024 }, (req, body, done) => done(null, body));
130
+
131
+ app.get('/api/tasks', (req, reply) => {
132
+ const parsed = scalarQuery(req.query, ['project', 'status', 'all', 'q']);
133
+ if (parsed.error) return reply.code(400).send({ error: parsed.error });
134
+ const { project, status, all, q } = parsed.values;
135
+ const p = project ? resolveProject(project) : null;
136
+ if (project && !p) return reply.code(404).send({ error: 'project not found' });
137
+ let sql = `
138
+ SELECT t.id, t.project_id, t.task_no, t.title, substr(t.description, 1, 180) AS description,
139
+ t.status, t.priority, t.blocked, t.blocked_reason, t.position, t.pinned,
140
+ t.work_seconds, t.work_started_at, t.work_truncated,
141
+ t.gh_issue_number, t.gh_issue_url, t.updated_at, t.labels,
142
+ p.slug AS project, (p.prefix || '-' || t.task_no) AS key,
143
+ (SELECT COUNT(*) FROM task_comments c WHERE c.task_id = t.id) AS comments_n,
144
+ (SELECT COUNT(*) FROM task_attachments a WHERE a.task_id = t.id AND a.file != '') AS attachments_n
145
+ FROM tasks t JOIN projects p ON p.id = t.project_id WHERE 1=1`;
146
+ const args = [];
147
+ if (p) { sql += ' AND t.project_id = ?'; args.push(p.id); }
148
+ else sql += ' AND p.archived = 0';
149
+ if (status) { sql += ' AND t.status = ?'; args.push(status); }
150
+ else if (!all) sql += " AND t.status NOT IN ('done','cancelled','backlog')";
151
+ if (q) {
152
+ sql += ` AND (instr(kb_lower(t.title), kb_lower(?)) > 0
153
+ OR instr(kb_lower(t.description), kb_lower(?)) > 0
154
+ OR instr(kb_lower(p.prefix || '-' || t.task_no), kb_lower(?)) > 0
155
+ OR instr(kb_lower(t.labels), kb_lower(?)) > 0)`;
156
+ args.push(q, q, q, q);
157
+ }
158
+ sql += ' ORDER BY t.status, t.priority DESC, t.position, t.id';
159
+ const list = db.prepare(sql).all(...args).map(listShape);
160
+ const linkMap = {};
161
+ const shownIds = list.map((t) => t.id);
162
+ const linkRows = shownIds.length
163
+ ? db.prepare(`SELECT task_id, linked_task_id FROM task_links
164
+ WHERE task_id IN (${shownIds.map(() => '?').join(',')})
165
+ OR linked_task_id IN (${shownIds.map(() => '?').join(',')})`).all(...shownIds, ...shownIds)
166
+ : [];
167
+ for (const { task_id, linked_task_id } of linkRows) {
168
+ (linkMap[task_id] ||= []).push(linked_task_id);
169
+ (linkMap[linked_task_id] ||= []).push(task_id);
170
+ }
171
+ for (const t of list) t.linked_ids = linkMap[t.id] || [];
172
+ return list;
173
+ });
174
+
175
+ app.get('/api/tasks/:id', async (req, reply) => {
176
+ const t = taskOr404(req, reply); if (!t) return reply;
177
+ const events = db.prepare('SELECT status, created_at FROM task_events WHERE task_id = ? ORDER BY id').all(t.id);
178
+ return { ...t, labels: JSON.parse(t.labels || '[]'), events, links: taskLinks(t.id), attachments: taskAttachments(t.id), checklist: taskChecklist(t.id), commit_url: await commitUrlFor(t) };
179
+ });
180
+
181
+ app.post('/api/tasks', (req, reply) => {
182
+ const { project, priority = 0, status = 'backlog', labels = [] } = req.body || {};
183
+ const title = String(req.body?.title ?? '');
184
+ const description = String(req.body?.description ?? '');
185
+ if (!project) return reply.code(400).send({ error: 'project required' });
186
+ if (!STATUSES.includes(status)) return reply.code(400).send({ error: 'bad status' });
187
+ if (!['backlog', 'todo'].includes(status)) {
188
+ return reply.code(400).send({ error: 'a task can only be created in Backlog or To do — everything else has to go through the working statuses' });
189
+ }
190
+ const p = resolveProject(project);
191
+ if (!p) return reply.code(404).send({ error: 'project not found' });
192
+ if (!Number.isInteger(priority) || priority < 0 || priority > 3) return reply.code(400).send({ error: 'priority: an integer 0-3' });
193
+ const labelsArr = normalizeLabels(labels);
194
+ if (labelsArr === null) return reply.code(400).send({ error: 'labels: an array of strings' });
195
+ const badCreate = unknownLabels(labelsArr);
196
+ if (badCreate.length) return reply.code(400).send(labelPaletteError(badCreate));
197
+ const pos = nextPosition(p.id, status);
198
+ const r = db.prepare('INSERT INTO tasks (project_id, task_no, title, description, priority, status, labels, position) VALUES (?,?,?,?,?,?,?,?)')
199
+ .run(p.id, nextTaskNo(p.id), title, description, priority, status, JSON.stringify(labelsArr), pos);
200
+ const t = resolveTask(String(r.lastInsertRowid));
201
+ enqueue('create_issue', t.id, snapshot(t));
202
+ kick();
203
+ emit('task.created', listShape({ ...t, comments_n: 0 }));
204
+ return reply.code(201).send({ ...t, labels: JSON.parse(t.labels) });
205
+ });
206
+
207
+ app.post('/api/tasks/:id/duplicate', (req, reply) => {
208
+ const src = taskOr404(req, reply); if (!src) return reply;
209
+ const status = ['backlog', 'todo'].includes(src.status) ? src.status : 'todo';
210
+ const pos = nextPosition(src.project_id, status);
211
+ const r = db.prepare('INSERT INTO tasks (project_id, task_no, title, description, priority, status, labels, position) VALUES (?,?,?,?,?,?,?,?)')
212
+ .run(src.project_id, nextTaskNo(src.project_id), `${src.title} (copy)`, src.description, src.priority, status, src.labels, pos);
213
+ const t = resolveTask(String(r.lastInsertRowid));
214
+ enqueue('create_issue', t.id, snapshot(t));
215
+ kick();
216
+ emit('task.created', listShape({ ...t, comments_n: 0 }));
217
+ return reply.code(201).send({ ...t, labels: JSON.parse(t.labels) });
218
+ });
219
+
220
+ app.patch('/api/tasks/:id', (req, reply) => {
221
+ const t = taskOr404(req, reply); if (!t) return reply;
222
+ const b = req.body || {};
223
+ const updates = [];
224
+ const values = [];
225
+
226
+ if (b.title !== undefined) { updates.push('title = ?'); values.push(String(b.title ?? '')); }
227
+ if (b.description !== undefined) { updates.push('description = ?'); values.push(String(b.description ?? '')); }
228
+ if (b.priority !== undefined) {
229
+ if (!Number.isInteger(b.priority) || b.priority < 0 || b.priority > 3) return reply.code(400).send({ error: 'priority: an integer 0-3' });
230
+ updates.push('priority = ?'); values.push(b.priority);
231
+ }
232
+ if (b.labels !== undefined) {
233
+ const labelsArr = normalizeLabels(b.labels);
234
+ if (labelsArr === null) return reply.code(400).send({ error: 'labels: an array of strings' });
235
+ const badPatch = unknownLabels(labelsArr);
236
+ if (badPatch.length) return reply.code(400).send(labelPaletteError(badPatch));
237
+ updates.push('labels = ?'); values.push(JSON.stringify(labelsArr));
238
+ }
239
+ if (b.blocked !== undefined) {
240
+ updates.push('blocked = ?'); values.push(b.blocked ? 1 : 0);
241
+ updates.push('blocked_reason = ?'); values.push(b.blocked ? (b.blocked_reason || '') : null);
242
+ }
243
+ if (b.position !== undefined) {
244
+ if (typeof b.position !== 'number' && typeof b.position !== 'string') {
245
+ return reply.code(400).send({ error: 'position: a finite number' });
246
+ }
247
+ const pos = Number(b.position);
248
+ if (!Number.isFinite(pos)) return reply.code(400).send({ error: 'position: a finite number' });
249
+ updates.push('position = ?'); values.push(pos);
250
+ }
251
+ if (b.pinned !== undefined) { updates.push('pinned = ?'); values.push(b.pinned ? 1 : 0); }
252
+ if (b.status !== undefined) {
253
+ if (!STATUSES.includes(b.status)) return reply.code(400).send({ error: 'bad status' });
254
+ const effLabels = b.labels !== undefined ? (normalizeLabels(b.labels) || []) : JSON.parse(t.labels || '[]');
255
+ const isNoclaude = Array.isArray(effLabels) && effLabels.includes('noclaude');
256
+ if (!isNoclaude && ['backlog', 'todo'].includes(t.status) && ['review', 'done'].includes(b.status)) {
257
+ return reply.code(400).send({ error: `cannot go straight from "${t.status}" to "${b.status}" — it has to pass through the working statuses` });
258
+ }
259
+ updates.push('status = ?'); values.push(b.status);
260
+ if (b.status === 'done' && t.status !== 'done') updates.push("done_at = datetime('now')");
261
+ else if (b.status !== 'done' && t.status === 'done') updates.push('done_at = NULL');
262
+ const working = (s) => s === 'doing' || s === 'deploy';
263
+ if (working(b.status) && !working(t.status)) {
264
+ updates.push("work_started_at = datetime('now')");
265
+ } else if (!working(b.status) && working(t.status)) {
266
+ updates.push("work_seconds = work_seconds + MIN(?, COALESCE(CAST(strftime('%s','now') AS INTEGER) - CAST(strftime('%s', work_started_at) AS INTEGER), 0))");
267
+ values.push(WORK_SEGMENT_MAX_S);
268
+ updates.push("work_truncated = work_truncated + (COALESCE(CAST(strftime('%s','now') AS INTEGER) - CAST(strftime('%s', work_started_at) AS INTEGER), 0) > ?)");
269
+ values.push(WORK_SEGMENT_MAX_S);
270
+ updates.push('work_started_at = NULL');
271
+ }
272
+ if (b.status !== t.status && b.position === undefined) {
273
+ const pos = nextPosition(t.project_id, b.status);
274
+ updates.push('position = ?'); values.push(pos);
275
+ }
276
+ }
277
+
278
+ if (!updates.length) return { ...t, labels: JSON.parse(t.labels || '[]') };
279
+ values.push(t.id);
280
+ const statusChanged = b.status !== undefined && b.status !== t.status;
281
+ const applyPatch = db.transaction(() => {
282
+ db.prepare(`UPDATE tasks SET ${updates.join(', ')}, updated_at = datetime('now') WHERE id = ?`).run(...values);
283
+ const f = resolveTask(String(t.id));
284
+
285
+ if (b.title !== undefined || b.description !== undefined) enqueue('update_issue', f.id, snapshot(f));
286
+ if (b.priority !== undefined && b.priority !== t.priority) enqueue('set_priority', f.id, snapshot(f));
287
+ if (b.labels !== undefined) enqueue('set_labels', f.id, snapshot(f));
288
+ if (b.blocked !== undefined && (b.blocked ? 1 : 0) !== t.blocked) enqueue('set_blocked', f.id, snapshot(f));
289
+ if (statusChanged) {
290
+ db.prepare('INSERT INTO task_events (task_id, status) VALUES (?, ?)').run(t.id, b.status);
291
+ const closed = (s) => s === 'done' || s === 'cancelled';
292
+ if (closed(b.status)) enqueue('close_issue', f.id, snapshot(f));
293
+ else if (closed(t.status)) enqueue('reopen_issue', f.id, snapshot(f));
294
+ else enqueue('set_status', f.id, snapshot(f));
295
+ }
296
+ return f;
297
+ });
298
+ const fresh = applyPatch();
299
+
300
+ if (statusChanged && b.status === 'review') attachGitStat(fresh, t.work_started_at);
301
+ kick();
302
+
303
+ emit('task.updated', { ...listShape({ ...fresh, comments_n: undefined }), prev_status: t.status });
304
+ return { ...fresh, labels: JSON.parse(fresh.labels || '[]') };
305
+ });
306
+
307
+ app.delete('/api/tasks/:id', (req, reply) => {
308
+ const t = taskOr404(req, reply); if (!t) return reply;
309
+ db.prepare('DELETE FROM sync_queue WHERE task_id = ?').run(t.id);
310
+ db.prepare('DELETE FROM tasks WHERE id = ?').run(t.id);
311
+ try { rmSync(join(ATTACH_DIR, String(t.id)), { recursive: true, force: true }); } catch { }
312
+ if (t.gh_issue_number) { enqueue('delete_issue', null, snapshot(t)); kick(); }
313
+ emit('task.deleted', { id: t.id, key: t.key, project: t.project });
314
+ return reply.code(204).send();
315
+ });
316
+
317
+ const withCommentImg = (c, taskId) => (c.image
318
+ ? { ...c, image_url: `/attachments/${taskId}/${c.image}`, image_path: join(ATTACH_DIR, String(taskId), c.image) }
319
+ : c);
320
+
321
+ app.get('/api/tasks/:id/comments', (req, reply) => {
322
+ const t = taskOr404(req, reply); if (!t) return reply;
323
+ return db.prepare('SELECT * FROM task_comments WHERE task_id = ? ORDER BY id').all(t.id).map((c) => withCommentImg(c, t.id));
324
+ });
325
+
326
+ app.post('/api/tasks/:id/comments', (req, reply) => {
327
+ const t = taskOr404(req, reply); if (!t) return reply;
328
+ const mime = String(req.headers['content-type'] || '').split(';')[0].trim();
329
+ if (mime.startsWith('image/')) {
330
+ const ext = MIME_EXT[mime];
331
+ if (!ext) return reply.code(400).send({ error: 'images only: png, jpeg, webp, gif' });
332
+ const buf = req.body;
333
+ if (!buf || !buf.length) return reply.code(400).send({ error: 'empty body' });
334
+ const imgs = db.prepare("SELECT COUNT(*) AS n FROM task_comments WHERE task_id = ? AND image IS NOT NULL AND image != ''").get(t.id).n;
335
+ if (imgs >= ATTACH_MAX) return reply.code(400).send({ error: `limit of images in comments: ${ATTACH_MAX}` });
336
+ const r = db.prepare("INSERT INTO task_comments (task_id, author, body, image) VALUES (?, 'me', '', '')").run(t.id);
337
+ const file = `c${r.lastInsertRowid}.${ext}`;
338
+ try {
339
+ mkdirSync(join(ATTACH_DIR, String(t.id)), { recursive: true });
340
+ writeFileSync(join(ATTACH_DIR, String(t.id), file), buf);
341
+ } catch {
342
+ db.prepare('DELETE FROM task_comments WHERE id = ?').run(r.lastInsertRowid);
343
+ return reply.code(500).send({ error: 'could not save the file' });
344
+ }
345
+ db.prepare('UPDATE task_comments SET image = ? WHERE id = ?').run(file, r.lastInsertRowid);
346
+ const c = withCommentImg(db.prepare('SELECT * FROM task_comments WHERE id = ?').get(r.lastInsertRowid), t.id);
347
+ emit('task.comment', { task_id: t.id, key: t.key, project: t.project, comment: c });
348
+ return reply.code(201).send(c);
349
+ }
350
+ const { body, author = 'me' } = req.body || {};
351
+ if (!body || !String(body).trim()) return reply.code(400).send({ error: 'body required' });
352
+ const r = db.prepare('INSERT INTO task_comments (task_id, author, body) VALUES (?,?,?)')
353
+ .run(t.id, author === 'claude' ? 'claude' : 'me', String(body).trim());
354
+ const c = db.prepare('SELECT * FROM task_comments WHERE id = ?').get(r.lastInsertRowid);
355
+ enqueue('add_comment', t.id, { ...snapshot(t), comment_author: c.author, comment_body: c.body });
356
+ kick();
357
+ emit('task.comment', { task_id: t.id, key: t.key, project: t.project, comment: c });
358
+ return reply.code(201).send(c);
359
+ });
360
+
361
+ app.delete('/api/tasks/:id/comments/:commentId', (req, reply) => {
362
+ const t = taskOr404(req, reply); if (!t) return reply;
363
+ const c = db.prepare('SELECT * FROM task_comments WHERE id = ? AND task_id = ?').get(req.params.commentId, t.id);
364
+ if (!c) return reply.code(404).send({ error: 'comment not found' });
365
+ if (c.image) { try { rmSync(join(ATTACH_DIR, String(t.id), c.image), { force: true }); } catch { } }
366
+ db.prepare('DELETE FROM task_comments WHERE id = ?').run(c.id);
367
+ emit('task.comment', { task_id: t.id, key: t.key, project: t.project });
368
+ return reply.code(204).send();
369
+ });
370
+
371
+ const taskOr404 = (req, reply, id = req.params.id, err = 'task not found') => {
372
+ const found = resolveTask(id);
373
+ if (!found) { reply.code(404).send({ error: err }); return null; }
374
+ return found;
375
+ };
376
+
377
+ app.post('/api/tasks/:id/links', (req, reply) => {
378
+ const t = taskOr404(req, reply); if (!t) return reply;
379
+ const other = req.body?.key
380
+ ? taskOr404(req, reply, req.body.key, 'the task to link to was not found')
381
+ : (reply.code(404).send({ error: 'the task to link to was not found' }), null);
382
+ if (!other) return reply;
383
+ if (other.id === t.id) return reply.code(400).send({ error: 'a task cannot be linked to itself' });
384
+ const rel = ['child', 'parent'].includes(req.body?.rel) ? req.body.rel : 'related';
385
+ removeLinkPair(t.id, other.id);
386
+ if (rel === 'child') db.prepare("INSERT INTO task_links (task_id, linked_task_id, kind) VALUES (?, ?, 'parent')").run(t.id, other.id);
387
+ else if (rel === 'parent') db.prepare("INSERT INTO task_links (task_id, linked_task_id, kind) VALUES (?, ?, 'parent')").run(other.id, t.id);
388
+ else { const [a, b] = [t.id, other.id].sort((x, y) => x - y); db.prepare("INSERT INTO task_links (task_id, linked_task_id, kind) VALUES (?, ?, 'related')").run(a, b); }
389
+ placeAdjacent(t, other);
390
+ emit('task.linked', { id: t.id, key: t.key });
391
+ return taskLinks(t.id);
392
+ });
393
+
394
+ app.delete('/api/tasks/:id/links/:linkedId', (req, reply) => {
395
+ const t = taskOr404(req, reply); if (!t) return reply;
396
+ const other = taskOr404(req, reply, req.params.linkedId, 'the task to link to was not found');
397
+ if (!other) return reply;
398
+ removeLinkPair(t.id, other.id);
399
+ emit('task.linked', { id: t.id, key: t.key });
400
+ return taskLinks(t.id);
401
+ });
402
+
403
+ app.post('/api/tasks/:id/attachments', (req, reply) => {
404
+ const t = taskOr404(req, reply); if (!t) return reply;
405
+ const mime = String(req.headers['content-type'] || '').split(';')[0].trim();
406
+ const ext = MIME_EXT[mime];
407
+ if (!ext) return reply.code(400).send({ error: 'images only: png, jpeg, webp, gif' });
408
+ const buf = req.body;
409
+ if (!buf || !buf.length) return reply.code(400).send({ error: 'empty body' });
410
+ const n = db.prepare("SELECT COUNT(*) AS n FROM task_attachments WHERE task_id = ? AND file != ''").get(t.id).n;
411
+ if (n >= ATTACH_MAX) return reply.code(400).send({ error: `limit of attachments per task: ${ATTACH_MAX}` });
412
+ const r = db.prepare('INSERT INTO task_attachments (task_id, file, mime) VALUES (?,?,?)').run(t.id, '', mime);
413
+ const file = `${r.lastInsertRowid}.${ext}`;
414
+ try {
415
+ mkdirSync(join(ATTACH_DIR, String(t.id)), { recursive: true });
416
+ writeFileSync(join(ATTACH_DIR, String(t.id), file), buf);
417
+ } catch {
418
+ db.prepare('DELETE FROM task_attachments WHERE id = ?').run(r.lastInsertRowid);
419
+ return reply.code(500).send({ error: 'could not save the file' });
420
+ }
421
+ db.prepare('UPDATE task_attachments SET file = ? WHERE id = ?').run(file, r.lastInsertRowid);
422
+ emit('task.attached', { task_id: t.id, key: t.key });
423
+ return reply.code(201).send({ id: r.lastInsertRowid, file, mime, url: `/attachments/${t.id}/${file}` });
424
+ });
425
+
426
+ app.get('/api/tasks/:id/attachments', (req, reply) => {
427
+ const t = taskOr404(req, reply); if (!t) return reply;
428
+ return taskAttachments(t.id);
429
+ });
430
+
431
+ app.delete('/api/tasks/:id/attachments/:attachmentId', (req, reply) => {
432
+ const t = taskOr404(req, reply); if (!t) return reply;
433
+ const a = db.prepare('SELECT * FROM task_attachments WHERE id = ? AND task_id = ?').get(req.params.attachmentId, t.id);
434
+ if (!a) return reply.code(404).send({ error: 'attachment not found' });
435
+ try { rmSync(join(ATTACH_DIR, String(t.id), a.file), { force: true }); } catch { }
436
+ db.prepare('DELETE FROM task_attachments WHERE id = ?').run(a.id);
437
+ emit('task.attached', { task_id: t.id, key: t.key });
438
+ return reply.code(204).send();
439
+ });
440
+
441
+ app.get('/api/tasks/:id/checklist', (req, reply) => {
442
+ const t = taskOr404(req, reply); if (!t) return reply;
443
+ return taskChecklist(t.id);
444
+ });
445
+
446
+ app.post('/api/tasks/:id/checklist', (req, reply) => {
447
+ const t = taskOr404(req, reply); if (!t) return reply;
448
+ const text = (req.body?.text ?? '').toString();
449
+ const mx = db.prepare('SELECT COALESCE(MAX(position), 0) AS m FROM task_checklist WHERE task_id = ?').get(t.id).m;
450
+ const r = db.prepare('INSERT INTO task_checklist (task_id, text, position) VALUES (?,?,?)').run(t.id, text, mx + 1);
451
+ return reply.code(201).send({ id: r.lastInsertRowid, text, done: false, position: mx + 1 });
452
+ });
453
+
454
+ app.patch('/api/tasks/:id/checklist/:itemId', (req, reply) => {
455
+ const t = taskOr404(req, reply); if (!t) return reply;
456
+ const item = db.prepare('SELECT * FROM task_checklist WHERE id = ? AND task_id = ?').get(req.params.itemId, t.id);
457
+ if (!item) return reply.code(404).send({ error: 'checklist item not found' });
458
+ const b = req.body || {};
459
+ const sets = [], vals = [];
460
+ if (b.text !== undefined) {
461
+ if (b.text === null || typeof b.text === 'object') return reply.code(400).send({ error: 'text: expected a string' });
462
+ sets.push('text = ?'); vals.push(String(b.text));
463
+ }
464
+ if (b.done !== undefined) { sets.push('done = ?'); vals.push(b.done ? 1 : 0); }
465
+ if (sets.length) { db.prepare(`UPDATE task_checklist SET ${sets.join(', ')} WHERE id = ?`).run(...vals, item.id); }
466
+ const fresh = db.prepare('SELECT id, text, done, position FROM task_checklist WHERE id = ?').get(item.id);
467
+ return { ...fresh, done: !!fresh.done };
468
+ });
469
+
470
+ app.delete('/api/tasks/:id/checklist/:itemId', (req, reply) => {
471
+ const t = taskOr404(req, reply); if (!t) return reply;
472
+ const item = db.prepare('SELECT * FROM task_checklist WHERE id = ? AND task_id = ?').get(req.params.itemId, t.id);
473
+ if (!item) return reply.code(404).send({ error: 'checklist item not found' });
474
+ db.prepare('DELETE FROM task_checklist WHERE id = ?').run(item.id);
475
+ return reply.code(204).send();
476
+ });
477
+ }
package/src/schema.sql ADDED
@@ -0,0 +1,135 @@
1
+ CREATE TABLE IF NOT EXISTS projects (
2
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3
+ slug TEXT NOT NULL UNIQUE,
4
+ prefix TEXT,
5
+ name TEXT NOT NULL,
6
+ path TEXT,
7
+ server_path TEXT,
8
+ deploy_skill TEXT,
9
+ server TEXT,
10
+ pm2_services TEXT,
11
+ domain TEXT,
12
+ category TEXT,
13
+ description TEXT,
14
+ gh_project_number INTEGER,
15
+ gh_project_id TEXT,
16
+ gh_status_field_id TEXT,
17
+ gh_status_options TEXT,
18
+ next_task_no INTEGER,
19
+ pinned INTEGER NOT NULL DEFAULT 0,
20
+ position REAL NOT NULL DEFAULT 0,
21
+ archived INTEGER NOT NULL DEFAULT 0,
22
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
23
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
24
+ );
25
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_prefix ON projects(prefix);
26
+ CREATE TABLE IF NOT EXISTS tasks (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ project_id INTEGER NOT NULL REFERENCES projects(id),
29
+ task_no INTEGER,
30
+ title TEXT NOT NULL,
31
+ description TEXT NOT NULL DEFAULT '',
32
+ status TEXT NOT NULL DEFAULT 'backlog'
33
+ CHECK (status IN ('backlog','todo','prep','doing','deploy','review','done','cancelled')),
34
+ priority INTEGER NOT NULL DEFAULT 0
35
+ CHECK (priority BETWEEN 0 AND 3),
36
+ labels TEXT NOT NULL DEFAULT '[]',
37
+ blocked INTEGER NOT NULL DEFAULT 0,
38
+ blocked_reason TEXT,
39
+ position REAL NOT NULL DEFAULT 0,
40
+ work_seconds INTEGER NOT NULL DEFAULT 0,
41
+ work_started_at TEXT,
42
+ work_truncated INTEGER NOT NULL DEFAULT 0,
43
+ pinned INTEGER NOT NULL DEFAULT 0,
44
+ gh_issue_number INTEGER,
45
+ gh_issue_url TEXT,
46
+ gh_item_id TEXT,
47
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
48
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
49
+ done_at TEXT
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_tasks_proj_status ON tasks(project_id, status);
52
+ CREATE INDEX IF NOT EXISTS idx_tasks_proj_status_blocked ON tasks(project_id, status, blocked);
53
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_proj_no ON tasks(project_id, task_no);
54
+ CREATE INDEX IF NOT EXISTS idx_tasks_done_at ON tasks(done_at);
55
+ CREATE TABLE IF NOT EXISTS task_comments (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
58
+ author TEXT NOT NULL DEFAULT 'me',
59
+ body TEXT NOT NULL,
60
+ image TEXT,
61
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
62
+ );
63
+ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id);
64
+ CREATE TABLE IF NOT EXISTS task_events (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
67
+ status TEXT NOT NULL,
68
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
69
+ );
70
+ CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id);
71
+ CREATE INDEX IF NOT EXISTS idx_events_created ON task_events(created_at);
72
+ CREATE TABLE IF NOT EXISTS task_links (
73
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
74
+ linked_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
75
+ kind TEXT NOT NULL DEFAULT 'related',
76
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
77
+ PRIMARY KEY (task_id, linked_task_id)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_links_linked ON task_links(linked_task_id);
80
+ CREATE TABLE IF NOT EXISTS task_attachments (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
83
+ file TEXT NOT NULL,
84
+ mime TEXT,
85
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
86
+ );
87
+ CREATE INDEX IF NOT EXISTS idx_attach_task ON task_attachments(task_id);
88
+ CREATE TABLE IF NOT EXISTS task_checklist (
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
91
+ text TEXT NOT NULL DEFAULT '',
92
+ done INTEGER NOT NULL DEFAULT 0,
93
+ position REAL NOT NULL DEFAULT 0,
94
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
95
+ );
96
+ CREATE INDEX IF NOT EXISTS idx_checklist_task ON task_checklist(task_id);
97
+ CREATE TABLE IF NOT EXISTS horizon_goals (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ scale TEXT NOT NULL,
100
+ period TEXT NOT NULL,
101
+ text TEXT NOT NULL DEFAULT '',
102
+ done INTEGER NOT NULL DEFAULT 0,
103
+ position REAL NOT NULL DEFAULT 0,
104
+ parent_id INTEGER REFERENCES horizon_goals(id) ON DELETE SET NULL,
105
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
106
+ );
107
+ CREATE INDEX IF NOT EXISTS idx_horizon_scale ON horizon_goals(scale, period);
108
+ CREATE TABLE IF NOT EXISTS kv (
109
+ key TEXT PRIMARY KEY,
110
+ value TEXT
111
+ );
112
+ CREATE TABLE IF NOT EXISTS app_errors (
113
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
114
+ at TEXT NOT NULL DEFAULT (datetime('now')),
115
+ source TEXT NOT NULL,
116
+ scope TEXT,
117
+ message TEXT NOT NULL,
118
+ detail TEXT,
119
+ op_id INTEGER,
120
+ resolved_at TEXT,
121
+ repeats INTEGER NOT NULL DEFAULT 0
122
+ );
123
+ CREATE INDEX IF NOT EXISTS idx_app_errors_at ON app_errors(id DESC);
124
+ CREATE TABLE IF NOT EXISTS sync_queue (
125
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
126
+ task_id INTEGER,
127
+ op TEXT NOT NULL,
128
+ payload TEXT NOT NULL,
129
+ attempts INTEGER NOT NULL DEFAULT 0,
130
+ next_attempt_at TEXT NOT NULL DEFAULT (datetime('now')),
131
+ last_error TEXT,
132
+ started_at TEXT,
133
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
134
+ );
135
+ CREATE INDEX IF NOT EXISTS idx_queue_next ON sync_queue(next_attempt_at);