vienalatina/apps/board/schema.sql
Claude 5ab9e3cab5
Phase C: private messages, with blocking and photos
An inbox between two members — conversations, per-person unread marks,
photos, blocking. Not live chat: that needs a connection held open per
signed-in member, which the sync workers cannot do.

Membership of the conversation is the whole access rule and is checked on
every hit, answering 404 rather than 403 so a member cannot tell a
conversation that is not theirs from one that does not exist. A picture
in a private message is checked the same way: on the board being signed
in is enough, here it is nowhere near.

Blocking is symmetric. One row stops both directions, and you can only
lift your own. A block that silenced only the blocked person would leave
the blocker writing freely, which is a megaphone rather than a safety
feature. Enforced in the handlers, with a test that posts from a page
held open from before the block.

Erasing a member deletes their private messages, both sides, and their
pictures off disk. A thread outlives its author because other people
replied; a two-party exchange has no remainder, and keeping half of
erased correspondence is what erasure exists to prevent. The guard added
in c9c549e did its job: it failed the moment the new tables landed and
named all four columns.

The part that needed care: schema.sql is all CREATE TABLE IF NOT EXISTS,
so it can add a table and nothing else. Every change so far happened to
be a new table. Letting an attachment belong to a message is not — and
SQLite cannot do it in place, because the table carries a CHECK
constraint and there is no DROP CONSTRAINT. Verified before building on
it: ALTER TABLE ADD COLUMN succeeds and the next insert is refused.

So migrations.py, numbered steps recorded in PRAGMA user_version, run
after the schema so a fresh database finds its work already done. Step 1
rebuilds attachments the documented way. Tested against a database built
in the old shape with rows in it, because a migration tested only on a
fresh database is tested against the one case it was never needed for —
including that the rebuilt CHECK is as strict as the one it replaced.

229 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NizVpJ2dwzCbjCrTLCjeHn
2026-09-28 16:10:52 +00:00

199 lines
8.6 KiB
SQL

-- Members area schema.
--
-- Applied at every startup and written to be idempotent, so deploying a new
-- version of the app needs no migration step for as long as the schema only
-- grows. A change that alters an existing column will need a real migration;
-- there is deliberately no framework here to pretend otherwise.
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY,
-- COLLATE NOCASE because Gitea treats logins case-insensitively; without it
-- "Pablo" and "pablo" would be two members with one Gitea account.
gitea_login TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'user', 'tombstone')),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
created_by INTEGER REFERENCES members(id),
last_seen_at TEXT
);
-- The one-owner rule, held by the database rather than by the application, so
-- a mistake in a handler cannot produce a second owner. SQLite enforces a
-- partial unique index exactly like a full one.
CREATE UNIQUE INDEX IF NOT EXISTS members_one_owner
ON members(role) WHERE role = 'owner';
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES members(id),
title TEXT NOT NULL,
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
-- Soft delete: a moderator's mistake stays recoverable, and removing one
-- comment does not tear a hole in the conversation around it.
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS threads_live
ON threads(pinned DESC, created_at DESC) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY,
thread_id INTEGER NOT NULL REFERENCES threads(id),
author_id INTEGER NOT NULL REFERENCES members(id),
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS comments_thread
ON comments(thread_id, created_at) WHERE deleted_at IS NULL;
-- Private messages between two members.
--
-- An inbox, not live chat: gunicorn's sync workers cannot hold a connection
-- open per signed-in member, and that would be the first thing on this box
-- with a real scaling limit.
--
-- Membership is its own table rather than two columns on `conversations`
-- because the unread mark is per person: each side keeps its own
-- `last_read_at`, and the badge counts messages newer than it that somebody
-- else wrote. Two columns would need two last-read fields and a rule about
-- which is which.
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS conversation_members (
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
member_id INTEGER NOT NULL REFERENCES members(id),
last_read_at TEXT,
PRIMARY KEY (conversation_id, member_id)
);
CREATE INDEX IF NOT EXISTS conversation_members_member
ON conversation_members(member_id);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
author_id INTEGER NOT NULL REFERENCES members(id),
body_md TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS messages_conversation
ON messages(conversation_id, created_at) WHERE deleted_at IS NULL;
-- Blocking is symmetric: one row stops messages in both directions.
--
-- The alternative — the blocker may still write, the blocked may not reply —
-- turns a safety feature into a one-way megaphone, which is worse than not
-- having one. Somebody who blocks a person and then wants to talk to them can
-- unblock. The CHECK is there because blocking yourself is meaningless and
-- would quietly disable your own inbox.
CREATE TABLE IF NOT EXISTS blocks (
blocker_id INTEGER NOT NULL REFERENCES members(id),
blocked_id INTEGER NOT NULL REFERENCES members(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id <> blocked_id)
);
CREATE INDEX IF NOT EXISTS blocks_blocked ON blocks(blocked_id);
-- Pictures attached to a thread, a comment or a private message.
--
-- The file itself lives in /data/uploads; this is the record of what it is and
-- what it belongs to. `stored_name` is generated, never the name the browser
-- sent, and is UNIQUE because it is also the URL.
--
-- The CHECK is the shape of the thing: an attachment hangs off exactly one of
-- the two, never both and never neither. Without it a row with both columns
-- set would be served under whichever parent was still alive, which is a
-- quiet way for a deleted thread's photo to stay readable.
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY,
thread_id INTEGER REFERENCES threads(id),
comment_id INTEGER REFERENCES comments(id),
message_id INTEGER REFERENCES messages(id),
stored_name TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
content_type TEXT NOT NULL,
bytes INTEGER NOT NULL,
uploaded_by INTEGER NOT NULL REFERENCES members(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK ((thread_id IS NOT NULL) + (comment_id IS NOT NULL)
+ (message_id IS NOT NULL) = 1)
);
CREATE INDEX IF NOT EXISTS attachments_thread ON attachments(thread_id);
CREATE INDEX IF NOT EXISTS attachments_comment ON attachments(comment_id);
CREATE INDEX IF NOT EXISTS attachments_message ON attachments(message_id);
-- Gitea access tokens for the editor.
--
-- Kept here rather than in the session cookie. Flask signs cookies but does not
-- encrypt them, so a live token sitting in one is readable by anything that can
-- read the cookie — and a token is enough to commit to the repository as its
-- owner. ON DELETE CASCADE ties the token to the membership: erasing a member
-- takes their token with it, with nothing to remember.
CREATE TABLE IF NOT EXISTS gitea_tokens (
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL DEFAULT '',
expires_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Frontmatter of content files, keyed by the git blob sha.
--
-- Listing a folder through Gitea's contents API returns names and shas but no
-- bodies, so showing titles and dates means fetching every file. Caching on the
-- sha turns that from one request per post on every page load into one request
-- in total, because a sha changes only when the file does. Nothing needs
-- invalidating: a row is only ever read for a path the listing still returns.
CREATE TABLE IF NOT EXISTS content_cache (
path TEXT PRIMARY KEY,
sha TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
date TEXT NOT NULL DEFAULT '',
categories TEXT NOT NULL DEFAULT '',
-- Files carrying `translated_from` are the pipeline's output, not anyone's
-- draft. Recorded here so the listing can skip them without re-reading
-- every file to find out what it already knew.
generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One-time links: invitations to set a first password, and password resets.
--
-- A token here is enough to take over an account, so only its SHA-256 lives in
-- this table. A database backup that leaks is then a list of useless hashes
-- rather than a set of live keys.
--
-- SHA-256 rather than a password hash on purpose: these are 32 random bytes
-- from secrets.token_urlsafe, not something a person chose. There is no
-- dictionary to run against them, so the slow hashing that protects weak
-- passwords buys nothing and costs a round trip on every click.
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'reset')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS invites_open
ON invites(member_id, purpose) WHERE used_at IS NULL;