Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
7.6 KiB
SQL
227 lines
7.6 KiB
SQL
CREATE TABLE dumps (
|
|
id TEXT PRIMARY KEY,
|
|
kind TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
comment TEXT,
|
|
user_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
url TEXT,
|
|
-- Lossy lookup key for "has this been dumped already?" — see api/lib/canonical-url.ts
|
|
url_canonical TEXT,
|
|
slug TEXT,
|
|
rich_content TEXT,
|
|
file_name TEXT,
|
|
file_mime TEXT,
|
|
file_size INTEGER,
|
|
vote_count INTEGER NOT NULL DEFAULT 0,
|
|
is_private INTEGER NOT NULL DEFAULT 0,
|
|
custom_thumbnail_mime TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'user',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
avatar_mime TEXT,
|
|
description TEXT,
|
|
invited_by TEXT REFERENCES users(id),
|
|
email TEXT NOT NULL,
|
|
chat_read_at TEXT,
|
|
tokens_valid_after TEXT
|
|
);
|
|
|
|
CREATE TABLE votes (
|
|
dump_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (dump_id, user_id),
|
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE playlists (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
slug TEXT,
|
|
description TEXT,
|
|
is_public INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
image_mime TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE playlist_dumps (
|
|
playlist_id TEXT NOT NULL,
|
|
dump_id TEXT NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (playlist_id, dump_id),
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE categories (
|
|
id TEXT PRIMARY KEY,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL UNIQUE,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT
|
|
);
|
|
|
|
CREATE TABLE dump_categories (
|
|
dump_id TEXT NOT NULL,
|
|
category_id TEXT NOT NULL,
|
|
PRIMARY KEY (dump_id, category_id),
|
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE comments (
|
|
id TEXT PRIMARY KEY,
|
|
dump_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
parent_id TEXT,
|
|
body TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
deleted INTEGER NOT NULL DEFAULT 0,
|
|
like_count INTEGER NOT NULL DEFAULT 0,
|
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE comment_likes (
|
|
comment_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (comment_id, user_id),
|
|
FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE dump_backlinks (
|
|
source_type TEXT NOT NULL,
|
|
source_id TEXT NOT NULL,
|
|
from_dump_id TEXT NOT NULL,
|
|
to_dump_id TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (source_type, source_id, to_dump_id),
|
|
FOREIGN KEY (from_dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (to_dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX idx_dumps_created ON dumps(created_at);
|
|
CREATE INDEX idx_dumps_user_created ON dumps(user_id, created_at);
|
|
CREATE INDEX idx_dumps_slug ON dumps(slug);
|
|
CREATE INDEX idx_dumps_url ON dumps(url);
|
|
CREATE INDEX idx_dumps_url_canonical ON dumps(url_canonical);
|
|
CREATE INDEX idx_votes_user_created ON votes(user_id, created_at);
|
|
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
|
CREATE INDEX idx_playlists_slug ON playlists(slug);
|
|
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
|
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
|
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
|
CREATE INDEX idx_comments_parent ON comments(parent_id);
|
|
CREATE INDEX idx_comments_user ON comments(user_id);
|
|
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
|
|
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
|
|
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
|
|
CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);
|
|
|
|
CREATE TABLE follows (
|
|
id TEXT PRIMARY KEY,
|
|
follower_id TEXT NOT NULL,
|
|
followed_user_id TEXT,
|
|
followed_playlist_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (followed_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (followed_playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
|
CHECK (
|
|
(followed_user_id IS NOT NULL AND followed_playlist_id IS NULL)
|
|
OR
|
|
(followed_user_id IS NULL AND followed_playlist_id IS NOT NULL)
|
|
)
|
|
);
|
|
|
|
CREATE UNIQUE INDEX idx_follows_user
|
|
ON follows(follower_id, followed_user_id)
|
|
WHERE followed_user_id IS NOT NULL;
|
|
|
|
CREATE UNIQUE INDEX idx_follows_playlist
|
|
ON follows(follower_id, followed_playlist_id)
|
|
WHERE followed_playlist_id IS NOT NULL;
|
|
|
|
CREATE INDEX idx_follows_follower ON follows(follower_id);
|
|
CREATE INDEX idx_follows_followed_user ON follows(followed_user_id);
|
|
CREATE INDEX idx_follows_followed_playlist ON follows(followed_playlist_id);
|
|
|
|
CREATE TABLE invites (
|
|
token TEXT PRIMARY KEY,
|
|
inviter_id TEXT NOT NULL,
|
|
used_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX idx_invites_inviter ON invites(inviter_id);
|
|
CREATE INDEX idx_users_invited_by ON users(invited_by);
|
|
CREATE UNIQUE INDEX idx_users_username_lower ON users(lower(username));
|
|
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email));
|
|
|
|
CREATE TABLE attachments (
|
|
id TEXT PRIMARY KEY,
|
|
resource_id TEXT,
|
|
mime TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
resource_type TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_attachments_resource ON attachments(resource_id);
|
|
|
|
CREATE TABLE password_reset_tokens (
|
|
token TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL,
|
|
used_at TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
|
|
|
CREATE TABLE notifications (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
read INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
source_key TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at);
|
|
CREATE UNIQUE INDEX idx_notifications_dedup
|
|
ON notifications(user_id, source_key)
|
|
WHERE source_key IS NOT NULL;
|
|
|
|
CREATE TABLE chat_messages (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
reply_to_id TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX idx_chat_messages_created_id ON chat_messages(created_at, id);
|
|
CREATE INDEX idx_chat_messages_user ON chat_messages(user_id);
|