feat(phase2,phase4): comments, attachments, notifications, Web Push, import/export

Comments and attachments close out phase 2. Files are stored
content-addressed by hash, so the path never contains anything the
uploader chose — a filename is attacker-controlled text, and building a
path from it is how "../" becomes a write outside the data directory.
Everything downloads as application/octet-stream: an uploaded SVG served
inline would run its own script on this origin with the session cookie
attached.

Web Push is written directly against RFC 8030/8291/8292 rather than
pulled in — it is three specs' worth of well-defined crypto and about
two hundred lines, and the alternatives carry a JWT and crypto stack
each larger than the file.

Phase 4 imports Todoist's CSV and exports it back. Todoist numbers
priorities the opposite way to how it displays them — its 4 is P1 — so
the two are converted rather than copied; getting it backwards silently
inverts the urgency of every imported task. Nesting is positional, so
the reader keeps an indent stack and the writer never sorts.

The round trip is tested as the brief asks: a CSV in, out, and back
produces an identical project, and two exports of one project are
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 07:42:55 +02:00
co-authored by Claude Opus 5
parent 0ee72d399c
commit 4058ff0533
13 changed files with 3215 additions and 0 deletions
@@ -0,0 +1,66 @@
-- Notifications, Web Push, and the mail-to-task address.
-- What a person needs to be told about: an assignment, a comment on something they
-- are in, an invite accepted. Stored rather than only pushed, so the bell icon has
-- a history and a notification survives the browser being shut.
CREATE TABLE notifications (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
actor_id TEXT REFERENCES users (id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
read_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_notifications_user ON notifications (user_id, created_at DESC);
CREATE INDEX idx_notifications_unread ON notifications (user_id) WHERE read_at IS NULL;
-- Web Push (RFC 8030) subscriptions. One row per browser per device: the same
-- person signed in on a laptop and a phone has two, and revoking one must not
-- silence the other.
CREATE TABLE push_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
last_used_at INTEGER,
-- A push service returns 404 or 410 when a subscription is dead. Counting the
-- failures lets a flaky network be forgiven while a genuinely gone endpoint is
-- eventually dropped.
failures INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_push_user ON push_subscriptions (user_id);
-- The VAPID keypair identifying this instance to push services. One row, ever.
-- Generated on first use rather than configured: it is not a secret an operator
-- should have to produce, and rotating it only costs re-subscribing.
CREATE TABLE instance_keys (
id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- The per-user address that turns an email into a task: todo+<token>@domain.
ALTER TABLE users ADD COLUMN mail_token TEXT;
CREATE UNIQUE INDEX idx_users_mail_token ON users (mail_token) WHERE mail_token IS NOT NULL;
-- Settings that belong to a person rather than to the instance: which AI provider
-- they use and with whose key, what their Gmail connection is. Kept as JSON for
-- the same reason templates are — written whole, read whole, and it would
-- otherwise be a migration every time an integration gains an option.
CREATE TABLE user_settings (
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'general',
values_json TEXT NOT NULL DEFAULT '{}',
updated_at INTEGER NOT NULL
);