/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
11 KiB
SQL
350 lines
11 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,
|
|
kind TEXT NOT NULL DEFAULT 'dump' CHECK (kind IN ('dump', 'playlist')),
|
|
slug TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
position INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT,
|
|
UNIQUE (kind, slug),
|
|
UNIQUE (kind, name)
|
|
);
|
|
|
|
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 playlist_categories (
|
|
playlist_id TEXT NOT NULL,
|
|
category_id TEXT NOT NULL,
|
|
PRIMARY KEY (playlist_id, category_id),
|
|
FOREIGN KEY (playlist_id) REFERENCES playlists(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 UNIQUE INDEX idx_dumps_user_slug ON dumps(user_id, 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 UNIQUE INDEX idx_playlists_user_slug ON playlists(user_id, 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 INDEX idx_playlist_categories_category ON playlist_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));
|
|
-- An account from another instance has no password (''), and its email may
|
|
-- also be a local account's.
|
|
CREATE UNIQUE INDEX idx_users_email_lower ON users(lower(email))
|
|
WHERE password_hash != '';
|
|
|
|
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 user_settings (
|
|
user_id TEXT PRIMARY KEY,
|
|
style TEXT,
|
|
color_scheme TEXT,
|
|
digest_weekly INTEGER NOT NULL DEFAULT 1,
|
|
digest_monthly INTEGER NOT NULL DEFAULT 1,
|
|
updated_at TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE digest_schedule (
|
|
user_id TEXT NOT NULL,
|
|
cadence TEXT NOT NULL,
|
|
window_start TEXT NOT NULL,
|
|
PRIMARY KEY (user_id, cadence),
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE dump_slug_aliases (
|
|
user_id TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (user_id, slug)
|
|
);
|
|
CREATE INDEX idx_dump_slug_aliases_dump ON dump_slug_aliases(dump_id, created_at);
|
|
|
|
CREATE TABLE playlist_slug_aliases (
|
|
user_id TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (user_id, slug)
|
|
);
|
|
CREATE INDEX idx_playlist_slug_aliases_playlist ON playlist_slug_aliases(playlist_id, created_at);
|
|
|
|
CREATE TABLE user_aliases (
|
|
username TEXT NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (username)
|
|
);
|
|
CREATE UNIQUE INDEX idx_user_aliases_lower ON user_aliases(lower(username));
|
|
CREATE INDEX idx_user_aliases_user ON user_aliases(user_id, created_at);
|
|
|
|
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,
|
|
mentions_everyone INTEGER NOT NULL DEFAULT 0,
|
|
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);
|
|
|
|
CREATE TABLE federation_keys (
|
|
kid TEXT PRIMARY KEY,
|
|
private_jwk TEXT NOT NULL,
|
|
public_jwk TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE user_identities (
|
|
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
issuer TEXT NOT NULL,
|
|
subject TEXT NOT NULL,
|
|
picture TEXT,
|
|
synced_at TEXT NOT NULL
|
|
);
|
|
CREATE UNIQUE INDEX idx_user_identities_subject
|
|
ON user_identities(issuer, subject);
|
|
|
|
CREATE TABLE federation_peers (
|
|
issuer TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
name TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE federation_logins (
|
|
state TEXT PRIMARY KEY,
|
|
issuer TEXT NOT NULL,
|
|
code_verifier TEXT NOT NULL,
|
|
nonce TEXT NOT NULL,
|
|
invite_token TEXT,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE oauth_codes (
|
|
code_hash TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
client_id TEXT NOT NULL,
|
|
redirect_uri TEXT NOT NULL,
|
|
code_challenge TEXT NOT NULL,
|
|
nonce TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE federation_grants (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
client_id TEXT NOT NULL,
|
|
client_name TEXT,
|
|
granted_at TEXT NOT NULL,
|
|
last_used_at TEXT NOT NULL,
|
|
PRIMARY KEY (user_id, client_id)
|
|
);
|
|
|
|
CREATE TABLE federation_outbox (
|
|
id TEXT PRIMARY KEY,
|
|
client_id TEXT NOT NULL,
|
|
event TEXT NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
next_attempt_at TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX idx_federation_outbox_next ON federation_outbox(next_attempt_at);
|