Files
stack/packages/tasks/src/fake.mjs
T
jason.woltjeandClaude Opus 5.5 7e73c2cd13 feat(tasks): the Vikunja v2 adapter, broker task verbs and sync (row 38, S3, darkwing)
packages/tasks adds the Vikunja v2 client, the eight task verbs, the
board-plus-cursor poll with its 60 s window and the digest. The broker
gains the task verbs and boots trackers from the boot config (lead
decisions 66 to 68). Due dates are truncated to the second and recorded
as truncated (B1). A write that lands but whose final read fails counts
as landed, in update and in create (B2).

Candidate agents/darkwing/work/slice1-s3, build-r2.patch 71ce87e6,
manifest e10e30e3 (28 files). Filbert approved round 2 on #1520
(comment 26853). Darkwing's post-reset rerun: test-release 14/14,
test-task 98/98 (comment 26857).

Integration gate in a worktree on c4baf779 with the patch applied:
bus 67, business 60, control-board 124, discord 173, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3. The three cohort kill cases (K1, K3,
K10) fail the same on the unpatched base, and the patch doesn't touch
the package. Every scripts/test-*.sh is green. test-release 14/14 and
test-task 98/98 ran on the existing gate2 compose network, because the
host's Docker address pools are exhausted. No network was created or
pruned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:40:48 -05:00

617 lines
26 KiB
JavaScript

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 };
}
}