import { createServer } from 'node:http'; import { randomBytes } from 'node:crypto'; import { NULL_DATE } from './digest.mjs'; // In-memory Vikunja v2.7.0 for tests. Statuses, error bodies and response shapes follow the // recordings in tests/fixtures/v2-shapes.json and the probe notes (probes.md, slice1-s3): // scope 401 before 404, bucket_id 0 outside the board and the move, `updated` reported to the // second but filtered on the stored value, moves between open buckets not bumping `updated`, // silent unassign, 403 on removing an absent label, and bot label visibility through the owner. const ROUTES = [ ['GET', /^\/info$/, null, 'info'], ['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'], ['GET', /^\/projects\/(\d+)$/, ['projects', 'read_one'], 'project'], ['GET', /^\/projects\/(\d+)\/views$/, ['projects_views', 'read_all'], 'views'], ['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets$/, ['projects', 'views_buckets'], 'buckets'], ['GET', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/tasks$/, ['projects', 'views_buckets_tasks_get'], 'board'], ['PUT', /^\/projects\/(\d+)\/views\/(\d+)\/buckets\/(\d+)\/tasks$/, ['projects', 'views_buckets_tasks'], 'move'], ['GET', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'read_all'], 'list'], ['POST', /^\/projects\/(\d+)\/tasks$/, ['tasks', 'create'], 'create'], ['GET', /^\/tasks\/(\d+)$/, ['tasks', 'read_one'], 'read'], ['PATCH', /^\/tasks\/(\d+)$/, ['tasks', 'update'], 'patch'], ['DELETE', /^\/tasks\/(\d+)$/, ['tasks', 'delete'], 'remove'], ['GET', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'read_all'], 'comments'], ['POST', /^\/tasks\/(\d+)\/comments$/, ['tasks_comments', 'create'], 'comment'], ['POST', /^\/tasks\/(\d+)\/labels$/, ['tasks_labels', 'create'], 'labelAdd'], ['DELETE', /^\/tasks\/(\d+)\/labels\/(\d+)$/, ['tasks_labels', 'delete'], 'labelRemove'], ['POST', /^\/tasks\/(\d+)\/assignees$/, ['tasks_assignees', 'create'], 'assign'], ['DELETE', /^\/tasks\/(\d+)\/assignees\/(\d+)$/, ['tasks_assignees', 'delete'], 'unassign'], ['POST', /^\/tasks\/(\d+)\/relations$/, ['tasks_relations', 'create'], 'relate'], ['DELETE', /^\/tasks\/(\d+)\/relations\/([a-z]+)\/(\d+)$/, ['tasks_relations', 'delete'], 'unrelate'], ]; const PATCH_KEYS = new Set([ 'title', 'description', 'done', 'due_date', 'priority', 'percent_done', 'bucket_id', 'hex_color', 'repeat_after', 'start_date', 'end_date', ]); const CREATE_KEYS = new Set(['title', 'description', 'due_date', 'priority', 'percent_done']); const UNAUTHORIZED = { code: 11, message: 'missing, malformed, expired or otherwise invalid token provided' }; const forbidden = (detail = 'Forbidden') => ({ title: 'Forbidden', status: 403, detail }); const noTask = { title: 'Not Found', status: 404, detail: 'This task does not exist', code: 4002 }; const noProject = { title: 'Not Found', status: 404, detail: 'The project does not exist.', code: 3001 }; const bad = (detail, code) => ({ title: 'Bad Request', status: 400, detail, ...(code ? { code } : {}) }); const unprocessable = (detail) => ({ title: 'Unprocessable Entity', status: 422, detail }); const second = (ms) => new Date(Math.floor(ms / 1000) * 1000).toISOString().replace('.000Z', 'Z'); // Vikunja v2.7.0 keeps a due date to the second; a write's answer echoes what was sent, in Go's form // (review r1, r1-vk-due-probe.txt). The probe can't tell floor from rounding; the adapter sends // whole seconds, so it doesn't matter to it. const dueIn = (x) => (x && x !== NULL_DATE ? second(Date.parse(x)) : null); const dueEcho = (x) => (x && x !== NULL_DATE ? new Date(x).toISOString().replace(/\.?0+Z$/, 'Z') : NULL_DATE); const fine = (ms) => new Date(ms).toISOString(); const page = (list, q) => { const per = Math.min(Math.max(Number(q.get('per_page')) || 50, 1), 50), n = Math.max(Number(q.get('page')) || 1, 1); return { items: list.slice((n - 1) * per, n * per), total: list.length, page: n, per_page: per, total_pages: Math.max(1, Math.ceil(list.length / per)), }; }; export class FakeVikunja { version; requests = []; #now; #seq = { user: 0, project: 0, view: 0, bucket: 0, task: 0, label: 0, comment: 0 }; #users = new Map(); #tokens = new Map(); #projects = new Map(); #views = new Map(); #buckets = new Map(); #tasks = new Map(); #labels = new Map(); #faults = []; constructor({ version = 'v2.7.0', now = () => Date.now() } = {}) { this.version = version; this.#now = now; } // Test setup. These act directly on the model, like an admin with database access. user(username, { owner = null } = {}) { const id = ++this.#seq.user; this.#users.set(id, { id, username, name: owner ? username.replace(/^bot-/, '') : '', bot: owner ?? 0, at: this.#now() }); return id; } token(user, scopes = '*', { expires = null } = {}) { const t = 'tk_' + randomBytes(20).toString('hex'); this.#tokens.set(t, { user, scopes, expires }); return t; } revoke(token) { this.#tokens.delete(token); } project(title, owner) { const id = ++this.#seq.project; this.#projects.set(id, { id, title, owner, shares: new Map(), index: 0 }); for (const [kind, position] of [ ['list', 100], ['gantt', 200], ['table', 300], ]) this.#view(id, kind[0].toUpperCase() + kind.slice(1), kind, position); const k = this.#view(id, 'Kanban', 'kanban', 400, 'manual'); const [todo, , done] = ['To-Do', 'Doing', 'Done'].map((t) => this.bucket(k, t)); Object.assign(this.#views.get(k), { default_bucket_id: todo, done_bucket_id: done }); return id; } share(project, user, permission = 0) { this.#projects.get(project).shares.set(user, permission); } bucket(view, title) { const id = ++this.#seq.bucket; this.#buckets.set(id, { id, title, view, position: id * 100 }); return id; } rename(bucket, title) { this.#buckets.get(bucket).title = title; } kanban(project) { return [...this.#views.values()].find((v) => v.project === project && v.view_kind === 'kanban'); } view(id) { return this.#views.get(id); } addView(project, kind, mode = 'manual') { return this.#view(project, kind, kind, 500, mode); } // The runbook's install: rename the three defaults, add in-review and blocked. install(project) { const k = this.kanban(project); const ids = [...this.#buckets.values()].filter((b) => b.view === k.id).map((b) => b.id); ['todo', 'in-progress', 'done'].forEach((t, i) => this.rename(ids[i], t)); this.bucket(k.id, 'in-review'); this.bucket(k.id, 'blocked'); return { view: k.id, buckets: this.bucketIds(k.id) }; } bucketIds(view) { return Object.fromEntries([...this.#buckets.values()].filter((b) => b.view === view).map((b) => [b.title, b.id])); } label(title, owner) { const id = ++this.#seq.label; this.#labels.set(id, { id, title, owner, at: this.#now() }); return id; } task(id) { return this.#tasks.get(id); } moveToProject(task, project) { const t = this.#tasks.get(task); t.project = project; t.buckets = new Map(); this.#place(t); this.#touch(t); } // The route name a request matched, for tests that compare the fake against the recordings. routeName(method, path) { return ROUTES.find(([m, re]) => m === method && re.test(path))?.[3] ?? null; } // fault(method, pattern, status, {apply}) fails the next matching request. Status 0 is a network // error. With apply the change lands first, as when a reply is lost after the commit. fault(method, pattern, status, { apply = false } = {}) { this.#faults.push({ method, pattern, status, apply }); } fetch = async (url, init = {}) => { const u = new URL(url); const method = (init.method ?? 'GET').toUpperCase(); const path = u.pathname.replace(/^\/api\/v2/, ''); const i = this.#faults.findIndex((f) => f.method === method && f.pattern.test(path)); const fault = i >= 0 ? this.#faults.splice(i, 1)[0] : null; if (fault && !fault.apply) { this.requests.push({ method, path, status: fault.status }); if (fault.status === 0) throw new TypeError('fetch failed'); return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status, detail: 'unexpected error occurred' }), { status: fault.status }); } const headers = new Headers(init.headers ?? {}); let body; try { body = init.body ? JSON.parse(init.body) : undefined; } catch { body = Symbol.for('bad'); } const r = u.pathname.startsWith('/api/v2/') ? this.#route(method, path, u.searchParams, headers, body) : { status: 404, body: { message: 'Not Found' } }; if (fault) { this.requests.push({ method, path, status: fault.status }); if (fault.status === 0) throw new TypeError('fetch failed'); return new Response(JSON.stringify({ title: 'Internal Server Error', status: fault.status }), { status: fault.status }); } this.requests.push({ method, path, status: r.status }); const h = { 'content-type': 'application/json', ...(r.etag ? { etag: r.etag } : {}) }; return new Response([204, 304].includes(r.status) ? null : JSON.stringify(r.body), { status: r.status, headers: h }); }; // The same model over HTTP on 127.0.0.1, for code that should use the platform fetch. async listen() { const server = createServer(async (req, res) => { const chunks = []; for await (const c of req) chunks.push(c); const r = await this.fetch('http://fake' + req.url, { method: req.method, headers: req.headers, body: chunks.length ? Buffer.concat(chunks).toString() : undefined, }).catch(() => null); if (!r) return res.destroy(); res.writeHead(r.status, Object.fromEntries(r.headers)); res.end(Buffer.from(await r.arrayBuffer())); }); await new Promise((ok) => server.listen(0, '127.0.0.1', ok)); return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((ok) => server.close(ok)), }; } #view(project, title, kind, position, mode = 'none') { const id = ++this.#seq.view; this.#views.set(id, { id, title, project, view_kind: kind, position, bucket_configuration_mode: mode, default_bucket_id: 0, done_bucket_id: 0, at: this.#now(), }); return id; } #access(user, project) { const p = this.#projects.get(project); if (!p) return null; if (p.owner === user) return 2; return p.shares.has(user) ? p.shares.get(user) : null; } #userJson(id) { const u = this.#users.get(id); if (!u) return null; return { id: u.id, name: u.name, username: u.username, ...(u.bot ? { bot_owner_id: u.bot } : {}), created: second(u.at), updated: second(u.at), }; } #labelJson(l) { return { id: l.id, title: l.title, description: '', hex_color: '', created_by: this.#userJson(l.owner), created: second(l.at), updated: second(l.at) }; } #visibleLabel(user, label) { const u = this.#users.get(user); if (label.owner === user) return true; // Upstream #3592: a bot reads labels its owner created and labels sibling bots created. if (u?.bot && (label.owner === u.bot || this.#users.get(label.owner)?.bot === u.bot)) return true; return [...this.#tasks.values()].some((t) => t.labels.includes(label.id) && this.#access(user, t.project) !== null); } #place(t) { for (const v of this.#views.values()) if (v.project === t.project && v.view_kind === 'kanban') t.buckets.set(v.id, t.done && v.done_bucket_id ? v.done_bucket_id : v.default_bucket_id); } #touch(t) { t.updated = Math.max(this.#now(), t.updated + 1); t.rev++; } #taskJson(t, { bucket = 0, empty = null, expand = false } = {}) { const related = {}; for (const r of t.relations) (related[r.kind] ??= []).push({ id: r.other }); return { id: t.id, title: t.title, description: t.description, project_id: t.project, done: t.done, done_at: t.doneAt ? fine(t.doneAt) : NULL_DATE, due_date: t.due ?? NULL_DATE, reminders: null, repeat_after: 0, repeat_mode: 0, priority: t.priority, start_date: NULL_DATE, end_date: NULL_DATE, assignees: t.assignees.length ? t.assignees.map((a) => this.#userJson(a)) : empty, labels: t.labels.length ? t.labels.map((l) => this.#labelJson(this.#labels.get(l))) : null, hex_color: '', percent_done: t.percent, identifier: `#${t.index}`, index: t.index, related_tasks: related, attachments: null, cover_image_attachment_id: 0, is_favorite: false, created: second(t.created), updated: second(t.updated), bucket_id: bucket, position: 0, reactions: null, created_by: this.#userJson(t.by), ...(expand ? { comment_count: t.comments.length } : {}), }; } #bucketJson(b, extra = {}) { return { id: b.id, title: b.title, project_view_id: b.view, ...extra, limit: 0, count: [...this.#tasks.values()].filter((t) => t.buckets.get(b.view) === b.id).length, position: b.position, created: second(this.#views.get(b.view).at), updated: second(this.#views.get(b.view).at), created_by: null, }; } #filter(text) { if (!text) return () => true; const tests = text.split(/\s*&&\s*/).map((clause) => { const m = /^(updated|id|done)\s*(>=|<=|!=|>|<|=)\s*(.+)$/.exec(clause.trim()); if (!m) return null; const [, field, op, raw] = m; let value; if (field === 'updated') { const v = raw.replace(/^['"]|['"]$/g, ''); if (!/^\d{4}-\d\d-\d\dT/.test(v) || !Number.isFinite(Date.parse(v))) return null; value = Date.parse(v); } else if (field === 'id') value = Number(raw); else value = raw === 'true'; const get = (t) => (field === 'updated' ? t.updated : field === 'id' ? t.id : t.done); return (t) => ({ '>': get(t) > value, '<': get(t) < value, '>=': get(t) >= value, '<=': get(t) <= value, '=': get(t) === value, '!=': get(t) !== value })[op]; }); if (tests.includes(null)) return null; return (t) => tests.every((f) => f(t)); } #route(method, path, q, headers, body) { const hit = ROUTES.map(([m, re, scope, name]) => [m, re.exec(path), scope, name]).find(([m, x]) => m === method && x); if (!hit) return { status: 404, body: { message: 'Not Found' } }; const [, match, scope, name] = hit; let user = null; if (scope) { const auth = /^Bearer (.+)$/.exec(headers.get('authorization') ?? ''); const tok = auth && this.#tokens.get(auth[1]); if (!tok || (tok.expires !== null && tok.expires <= this.#now())) return { status: 401, body: UNAUTHORIZED }; // The token check runs before the handler, so a missing scope is 401 even for a missing task. if (tok.scopes !== '*' && !tok.scopes[scope[0]]?.includes(scope[1])) return { status: 401, body: UNAUTHORIZED }; user = tok.user; } if (body === Symbol.for('bad')) return { status: 400, body: bad('Invalid model provided.') }; const args = match.slice(1).map((x) => (/^\d+$/.test(x) ? Number(x) : x)); return this[`_${name}`](user, args, q, body ?? {}, headers); } #taskFor(user, id, write = false) { const t = this.#tasks.get(id); if (!t) return { error: { status: 404, body: noTask } }; const a = this.#access(user, t.project); if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden(a === null ? "You don't have the permission to see this" : 'Forbidden') } }; return { t }; } #projectFor(user, id, write = false) { if (!this.#projects.has(id)) return { error: { status: 404, body: noProject } }; const a = this.#access(user, id); if (a === null || (write && a < 1)) return { error: { status: 403, body: forbidden("You don't have the permission to see this") } }; return { p: this.#projects.get(id) }; } _info() { return { status: 200, body: { version: this.version, max_items_per_page: 50, task_comments_enabled: true } }; } _labels(user, _a, q) { const list = [...this.#labels.values()].filter((l) => this.#visibleLabel(user, l)).map((l) => this.#labelJson(l)); return { status: 200, body: page(list, q) }; } _project(user, [p]) { const r = this.#projectFor(user, p); if (r.error) return r.error; return { status: 200, body: { id: p, title: r.p.title, description: '', owner: this.#userJson(r.p.owner), is_archived: false } }; } _views(user, [p], q) { const r = this.#projectFor(user, p); if (r.error) return r.error; const list = [...this.#views.values()] .filter((v) => v.project === p) .map((v) => ({ id: v.id, title: v.title, project_id: p, view_kind: v.view_kind, filter: null, position: v.position, bucket_configuration_mode: v.bucket_configuration_mode, bucket_configuration: null, default_bucket_id: v.default_bucket_id, done_bucket_id: v.done_bucket_id, updated: second(v.at), created: second(v.at), })); return { status: 200, body: page(list, q) }; } _buckets(user, [p, k], q) { const r = this.#projectFor(user, p); if (r.error) return r.error; if (this.#views.get(k)?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } }; return { status: 200, body: page([...this.#buckets.values()].filter((b) => b.view === k).map((b) => this.#bucketJson(b)), q) }; } _board(user, [p, k], q) { const r = this.#projectFor(user, p); if (r.error) return r.error; const v = this.#views.get(k); if (v?.project !== p) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This project view does not exist.', code: 13001 } }; const f = q.get('filter') ?? ''; if (f && f !== 'done = false') return { status: 400, body: bad('unsupported filter in the fake') }; const per = Math.min(Number(q.get('per_page')) || 50, 50), n = Math.max(Number(q.get('page')) || 1, 1); const items = [...this.#buckets.values()] .filter((b) => b.view === k) .map((b) => { const all = [...this.#tasks.values()].filter((t) => t.buckets.get(k) === b.id && (!f || !t.done)).sort((a, b) => a.id - b.id); const tasks = all.slice((n - 1) * per, n * per).map((t) => this.#taskJson(t, { bucket: b.id })); return { ...this.#bucketJson(b, { tasks: tasks.length ? tasks : null }), count: all.length }; }); return { status: 200, body: { items } }; } _move(user, [p, k, b], _q, body) { const r = this.#projectFor(user, p, true); if (r.error) return r.error; const v = this.#views.get(k), bucket = this.#buckets.get(b); if (v?.project !== p || bucket?.view !== k) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'This bucket does not exist.', code: 10001 } }; const x = this.#taskFor(user, body.task_id, true); if (x.error) return x.error; const t = x.t; if (t.project !== p) return { status: 400, body: bad('task is in another project') }; const was = t.buckets.get(k); t.buckets.set(k, b); if (b === v.done_bucket_id && !t.done) { t.done = true; t.doneAt = this.#now(); this.#touch(t); } else if (was === v.done_bucket_id && b !== was && t.done) { t.done = false; t.doneAt = null; this.#touch(t); } else t.rev++; return { status: 200, body: { bucket_id: b, bucket: this.#bucketJson(bucket), task_id: t.id, project_view_id: k, task: this.#taskJson(t) }, }; } _list(user, [p], q) { const r = this.#projectFor(user, p); if (r.error) return r.error; const f = this.#filter(q.get('filter')); if (!f) return { status: 400, body: bad('Invalid filter expression', 1) }; const sorts = q.getAll('sort_by'), orders = q.getAll('order_by'); const list = [...this.#tasks.values()].filter((t) => t.project === p && f(t)); list.sort((a, b) => { for (const [i, s] of (sorts.length ? sorts : ['id']).entries()) { const d = (s === 'updated' ? a.updated - b.updated : a.id - b.id) * (orders[i] === 'desc' ? -1 : 1); if (d) return d; } return 0; }); const expand = q.get('expand') === 'comment_count'; const out = page(list, q); out.items = out.items.map((t) => this.#taskJson(t, { empty: null, expand })); return { status: 200, body: out }; } _create(user, [p], _q, body) { const r = this.#projectFor(user, p, true); if (r.error) return r.error; if (Object.keys(body).some((k) => !CREATE_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') }; if (typeof body.title !== 'string' || !body.title.trim()) return { status: 400, body: bad('The task title cannot be empty.', 4001) }; const now = this.#now(); const t = { id: ++this.#seq.task, project: p, index: ++r.p.index, title: body.title, description: body.description ?? '', done: false, doneAt: null, due: dueIn(body.due_date), priority: body.priority ?? 0, percent: body.percent_done ?? 0, labels: [], assignees: [], relations: [], comments: [], buckets: new Map(), created: now, updated: now, by: user, rev: 0, }; this.#place(t); this.#tasks.set(t.id, t); const out = this.#taskJson(t, { empty: [] }); out.related_tasks = null; if ('due_date' in body) out.due_date = dueEcho(body.due_date); out.created = fine(now); out.updated = fine(now); return { status: 201, body: out }; } _read(user, [id], _q, _b, headers) { const x = this.#taskFor(user, id); if (x.error) return x.error; const etag = `"${x.t.id}-${x.t.rev}"`; if (headers.get('if-none-match') === etag) return { status: 304, etag }; return { status: 200, body: this.#taskJson(x.t), etag }; } _patch(user, [id], _q, body) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (Object.keys(body).some((k) => !PATCH_KEYS.has(k))) return { status: 422, body: unprocessable('unknown field') }; const t = x.t, before = JSON.stringify(this.#taskJson(t)); if ('title' in body) t.title = body.title; if ('description' in body) t.description = body.description; if ('due_date' in body) t.due = dueIn(body.due_date); if ('priority' in body) t.priority = body.priority; if ('percent_done' in body) t.percent = body.percent_done; if ('done' in body && body.done !== t.done) { t.done = body.done; t.doneAt = t.done ? this.#now() : null; for (const v of this.#views.values()) if (v.project === t.project && v.view_kind === 'kanban') t.buckets.set(v.id, t.done ? v.done_bucket_id : v.default_bucket_id); } // bucket_id is echoed and not applied (probes.md, "Writes and moves"). if (JSON.stringify(this.#taskJson(t)) === before && !('bucket_id' in body)) return { status: 304 }; if (JSON.stringify(this.#taskJson(t)) !== before) this.#touch(t); const out = this.#taskJson(t, { empty: [], bucket: body.bucket_id ?? 0 }); if ('due_date' in body) out.due_date = dueEcho(body.due_date); return { status: 200, body: out }; } _remove(user, [id]) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; this.#tasks.delete(id); return { status: 204 }; } _comments(user, [id], q) { const x = this.#taskFor(user, id); if (x.error) return x.error; return { status: 200, body: page(x.t.comments.map((c) => ({ ...c, created: second(c.at), updated: second(c.at), at: undefined })), q) }; } _comment(user, [id], _q, body) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (typeof body.comment !== 'string' || !body.comment) return { status: 400, body: bad('comment required') }; const at = this.#now(); const c = { id: ++this.#seq.comment, comment: body.comment, author: this.#userJson(user), reactions: null, at }; x.t.comments.push(c); this.#touch(x.t); return { status: 201, body: { id: c.id, comment: c.comment, author: c.author, reactions: null, created: fine(at), updated: fine(at) } }; } _labelAdd(user, [id], _q, body) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; const l = this.#labels.get(body.label_id); if (!l || !this.#visibleLabel(user, l)) return { status: 403, body: forbidden() }; if (x.t.labels.includes(l.id)) return { status: 400, body: bad('The label already exists on the task.', 8001) }; x.t.labels.push(l.id); this.#touch(x.t); return { status: 201, body: { label_id: l.id, created: fine(this.#now()) } }; } _labelRemove(user, [id, label]) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (!x.t.labels.includes(label)) return { status: 403, body: forbidden() }; x.t.labels = x.t.labels.filter((l) => l !== label); this.#touch(x.t); return { status: 204 }; } _assign(user, [id], _q, body) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (!this.#users.has(body.user_id)) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The user does not exist.', code: 1005 } }; if (x.t.assignees.includes(body.user_id)) return { status: 400, body: bad('This user is already assigned to that task.', 4021) }; x.t.assignees.push(body.user_id); this.#touch(x.t); return { status: 201, body: { user_id: body.user_id, created: NULL_DATE } }; } _unassign(user, [id, who]) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (x.t.assignees.includes(who)) { x.t.assignees = x.t.assignees.filter((a) => a !== who); this.#touch(x.t); } return { status: 204 }; } _relate(user, [id], _q, body) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; if (!this.#tasks.has(body.other_task_id)) return { status: 404, body: noTask }; x.t.relations.push({ kind: body.relation_kind, other: body.other_task_id }); this.#touch(x.t); return { status: 201, body: { task_id: id, other_task_id: body.other_task_id, relation_kind: body.relation_kind, created_by: this.#userJson(user), created: fine(this.#now()) } }; } _unrelate(user, [id, kind, other]) { const x = this.#taskFor(user, id, true); if (x.error) return x.error; const i = x.t.relations.findIndex((r) => r.kind === kind && r.other === other); if (i < 0) return { status: 404, body: { title: 'Not Found', status: 404, detail: 'The task relation does not exist.', code: 4009 } }; x.t.relations.splice(i, 1); this.#touch(x.t); return { status: 204 }; } }