Files
stack/packages/tasks/src/fake.mjs
T
jason.woltjeandClaude Opus 5.5 390d437ba2 feat(tasks): the adapter reads a project tree (#1555, row 62)
With tracker.descendants on, the tracker project is the root of a tree.
Each reconcile reads the sync bot's project list (archived projects and
negative pseudo-project ids left out) and walks it by parent_project_id.
A child that passes the install checks is read through its board, and
one that doesn't is watch-only (bucket 0) and never refuses the
business. A tick reads each project in the tree. A child answering 403
or 404 leaves the tree only if GET /projects/<id> then answers 403 or
404 too, and its open tasks get the one-by-one read. Task refs carry
the project, so a task moved inside the tree shows under its new ref
and the old ref gets its tombstone. tracker.descendants reaches the adapter
through the business var and packages/cli/src/config.mjs (lead decision
87). Off, the adapter reads as v0 did.

Darkwing built it (agents/darkwing/work/queue-62), in three rounds.
Round 3 was approved by Filbert (27317) and Dewey (27338) on manifest
8b81e836, 13 paths. Their non-blocking notes and Darkwing's follow-ups
(two surviving mutants in test 18, the fake's view-404 code) go to a
follow-up row.

Sage's gate on 918a4a19, sequential and teed: node suites tasks 71,
business 60, bus 80, cli 110 and queue 150, 0 failed; webui 39 of 40,
then 40 of 40 on three reruns (row 62 touches no webui file; a timing
test under load 17 with Dewey's gate running beside it); test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98, all 0 failed (Docker on). edc5de71..HEAD
touches none of the 13 paths.

Live read, 2026-10-11T21:28:03Z, tasks.woltje.com v2.7.0, root #19,
descendants on, sync key only, GETs only (the startup scope probes not
sent): reconcile 15 GETs, 42 snapshots; tick 8 GETs, 0 snapshots.
Children 18, 20 and 21 are watch-only. A missing view id on #18 answers
404 (code 3014) while #18 reads 200, as the README says. Record:
agents/sage/work/vikunja-setup/2026-10-11_tick-62.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:29:13 -05:00

686 lines
30 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.
// Project trees follow the v2.7.0 source (row 62 probes.md): a grant on a project reaches every
// descendant, the nearest grant wins unless one on the path is admin, archiving a project archives
// its descendants, and the project list leaves out archived projects unless asked for them.
const ROUTES = [
['GET', /^\/info$/, null, 'info'],
['GET', /^\/labels$/, ['labels', 'read_all'], 'labels'],
['GET', /^\/projects$/, ['projects', 'read_all'], 'projects'],
['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 = [];
#pseudo = [];
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, { parent = 0 } = {}) {
const id = ++this.#seq.project;
this.#projects.set(id, { id, title, owner, parent, archived: !!this.#projects.get(parent)?.archived, 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);
}
unshare(project, user) {
this.#projects.get(project).shares.delete(user);
}
// Moves a project under another parent, or to the top level with 0.
reparent(project, parent) {
this.#projects.get(project).parent = parent;
}
// Vikunja writes is_archived down the tree, so a descendant reads as archived too.
archive(project, archived = true) {
for (const p of this.#projects.values()) if (p.id === project || this.#ancestors(p.id).includes(project)) p.archived = archived;
}
// Vikunja deletes a project's descendants and every task in them with it.
deleteProject(project) {
const gone = [...this.#projects.keys()].filter((id) => id === project || this.#ancestors(id).includes(project));
for (const t of [...this.#tasks.values()]) if (gone.includes(t.project)) this.#tasks.delete(t.id);
for (const id of gone) this.#projects.delete(id);
}
// Drops a view, its buckets and the tasks' places in them. The project still reads, and a read
// of the view answers 404 with code 13001.
deleteView(view) {
for (const [id, b] of this.#buckets) if (b.view === view) this.#buckets.delete(id);
for (const t of this.#tasks.values()) t.buckets.delete(view);
this.#views.delete(view);
}
// A saved filter or Favorites: a negative id in the project list, as Project.ReadAll appends.
pseudoProject(user, id = -1, title = 'Favorites') {
this.#pseudo.push({ id, title, user });
}
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;
}
// Parents, nearest first. A cycle can't be made through the setup methods the tests use.
#ancestors(project) {
const out = [];
for (let p = this.#projects.get(project)?.parent; p && !out.includes(p); p = this.#projects.get(p)?.parent) out.push(p);
return out;
}
// project_access.go: owner or a grant on the project or any ancestor; admin anywhere wins,
// otherwise the nearest grant.
#access(user, project) {
if (!this.#projects.has(project)) return null;
let nearest = null;
for (const id of [project, ...this.#ancestors(project)]) {
const p = this.#projects.get(id);
if (!p) break;
if (p.owner === user) return 2;
const a = p.shares.has(user) ? p.shares.get(user) : null;
if (a === 2) return 2;
nearest ??= a;
}
return nearest;
}
#projectJson(p) {
return {
id: p.id,
title: p.title,
description: '',
owner: this.#userJson(p.owner),
parent_project_id: p.parent,
is_archived: p.archived,
position: p.id * 100,
created: second(0),
updated: second(0),
};
}
#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: this.#projectJson(r.p) };
}
_projects(user, _a, q) {
const archived = q.get('is_archived') === 'true';
const list = [...this.#projects.values()]
.filter((p) => this.#access(user, p.id) !== null && (archived || !p.archived))
.map((p) => this.#projectJson(p));
for (const x of this.#pseudo) if (x.user === user) list.push({ id: x.id, title: x.title, description: '', owner: this.#userJson(user), parent_project_id: 0, is_archived: false });
return { status: 200, body: page(list, q) };
}
_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 };
}
}