306 lines
12 KiB
SQL
306 lines
12 KiB
SQL
-- verdande — initial schema.
|
|||
|
|
--
|
||
|
|
-- Conventions used throughout:
|
||
|
|
-- * ids are UUIDv7 stored as TEXT: time-sortable, so they index well and give a
|
||
|
|
-- stable creation order without a second column.
|
||
|
|
-- * timestamps are INTEGER unix seconds, UTC. The one exception is tasks.due_date,
|
||
|
|
-- which is a calendar date ('YYYY-MM-DD') and deliberately has no timezone —
|
||
|
|
-- "due Tuesday" means Tuesday wherever you happen to be standing.
|
||
|
|
-- * sort_order is REAL, not INTEGER, so drag-and-drop can insert between two
|
||
|
|
-- neighbours by averaging them instead of renumbering the whole list.
|
||
|
|
-- * soft delete is `deleted_at`; the 30-day trash sweeper is what makes it final.
|
||
|
|
|
||
|
|
CREATE TABLE users (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
email TEXT NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
password_hash TEXT NOT NULL,
|
||
|
|
totp_secret TEXT,
|
||
|
|
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||
|
|
avatar_color TEXT NOT NULL DEFAULT '#8a8f98',
|
||
|
|
timezone TEXT NOT NULL DEFAULT 'Europe/Copenhagen',
|
||
|
|
locale TEXT NOT NULL DEFAULT 'da',
|
||
|
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
-- Email identifies a person for login and invites, so it is unique case-insensitively.
|
||
|
|
-- Addresses are also stored already lowercased; this index is the backstop.
|
||
|
|
CREATE UNIQUE INDEX idx_users_email ON users (lower(email));
|
||
|
|
|
||
|
|
CREATE TABLE sessions (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
ua TEXT NOT NULL DEFAULT '',
|
||
|
|
ip TEXT NOT NULL DEFAULT '',
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
last_seen_at INTEGER NOT NULL,
|
||
|
|
expires_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_sessions_user ON sessions (user_id);
|
||
|
|
CREATE INDEX idx_sessions_expires ON sessions (expires_at);
|
||
|
|
|
||
|
|
-- Invites carry a hashed token, never the token itself: the plaintext lives only in
|
||
|
|
-- the emailed link. A leaked database therefore cannot be used to accept invites.
|
||
|
|
CREATE TABLE invites (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
email TEXT NOT NULL,
|
||
|
|
project_id TEXT REFERENCES projects (id) ON DELETE CASCADE,
|
||
|
|
role TEXT NOT NULL DEFAULT 'editor',
|
||
|
|
token_hash TEXT NOT NULL UNIQUE,
|
||
|
|
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
expires_at INTEGER NOT NULL,
|
||
|
|
accepted_at INTEGER,
|
||
|
|
CHECK (role IN ('owner', 'editor', 'viewer'))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_invites_email ON invites (lower(email));
|
||
|
|
|
||
|
|
-- Single-use tokens for "forgot my password". Same hashed-token rule as invites.
|
||
|
|
CREATE TABLE password_resets (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
token_hash TEXT NOT NULL UNIQUE,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
expires_at INTEGER NOT NULL,
|
||
|
|
used_at INTEGER
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TABLE projects (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
color TEXT NOT NULL DEFAULT 'graphite',
|
||
|
|
icon TEXT,
|
||
|
|
view_mode TEXT NOT NULL DEFAULT 'list',
|
||
|
|
owner_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
is_inbox INTEGER NOT NULL DEFAULT 0,
|
||
|
|
archived INTEGER NOT NULL DEFAULT 0,
|
||
|
|
sort_order REAL NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
deleted_at INTEGER,
|
||
|
|
CHECK (view_mode IN ('list', 'board', 'calendar'))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_projects_owner ON projects (owner_id) WHERE deleted_at IS NULL;
|
||
|
|
|
||
|
|
-- Exactly one Inbox per user, enforced here rather than in application code.
|
||
|
|
CREATE UNIQUE INDEX idx_projects_one_inbox ON projects (owner_id) WHERE is_inbox = 1;
|
||
|
|
|
||
|
|
CREATE TABLE project_members (
|
||
|
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
role TEXT NOT NULL,
|
||
|
|
added_at INTEGER NOT NULL,
|
||
|
|
PRIMARY KEY (project_id, user_id),
|
||
|
|
CHECK (role IN ('owner', 'editor', 'viewer'))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_project_members_user ON project_members (user_id);
|
||
|
|
|
||
|
|
CREATE TABLE sections (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
sort_order REAL NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
deleted_at INTEGER
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_sections_project ON sections (project_id) WHERE deleted_at IS NULL;
|
||
|
|
|
||
|
|
CREATE TABLE tasks (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||
|
|
section_id TEXT REFERENCES sections (id) ON DELETE SET NULL,
|
||
|
|
parent_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||
|
|
content TEXT NOT NULL,
|
||
|
|
description TEXT NOT NULL DEFAULT '',
|
||
|
|
priority INTEGER NOT NULL DEFAULT 4,
|
||
|
|
-- due_date is the calendar day ('YYYY-MM-DD'); due_datetime pins it to a moment
|
||
|
|
-- when the task has a clock time. A timed task sets both, so day-based queries
|
||
|
|
-- never have to do timezone maths.
|
||
|
|
due_date TEXT,
|
||
|
|
due_datetime INTEGER,
|
||
|
|
due_timezone TEXT,
|
||
|
|
duration_min INTEGER,
|
||
|
|
recurrence_rule TEXT,
|
||
|
|
assignee_id TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||
|
|
created_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
completed_at INTEGER,
|
||
|
|
completed_by TEXT REFERENCES users (id) ON DELETE SET NULL,
|
||
|
|
sort_order REAL NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
deleted_at INTEGER,
|
||
|
|
-- A Danish transliteration of the searchable text, kept beside the original so
|
||
|
|
-- full-text search can match either spelling. See tasks_fts below for why.
|
||
|
|
fold TEXT GENERATED ALWAYS AS (
|
||
|
|
replace(replace(replace(replace(replace(replace(
|
||
|
|
content || ' ' || description,
|
||
|
|
'ø', 'o'), 'Ø', 'o'),
|
||
|
|
'æ', 'ae'), 'Æ', 'ae'),
|
||
|
|
'å', 'aa'), 'Å', 'aa')
|
||
|
|
) STORED,
|
||
|
|
CHECK (priority BETWEEN 1 AND 4)
|
||
|
|
);
|
||
|
|
|
||
|
|
-- The three access patterns that carry the app: a project's open tasks, the Today
|
||
|
|
-- and Upcoming views, and "what is assigned to me".
|
||
|
|
CREATE INDEX idx_tasks_project ON tasks (project_id, sort_order)
|
||
|
|
WHERE deleted_at IS NULL AND completed_at IS NULL;
|
||
|
|
CREATE INDEX idx_tasks_due ON tasks (due_date)
|
||
|
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND due_date IS NOT NULL;
|
||
|
|
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id)
|
||
|
|
WHERE deleted_at IS NULL AND completed_at IS NULL AND assignee_id IS NOT NULL;
|
||
|
|
CREATE INDEX idx_tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
|
||
|
|
|
||
|
|
CREATE TABLE labels (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
color TEXT NOT NULL DEFAULT 'graphite',
|
||
|
|
sort_order REAL NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE UNIQUE INDEX idx_labels_user_name ON labels (user_id, lower(name));
|
||
|
|
|
||
|
|
CREATE TABLE task_labels (
|
||
|
|
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
|
||
|
|
label_id TEXT NOT NULL REFERENCES labels (id) ON DELETE CASCADE,
|
||
|
|
PRIMARY KEY (task_id, label_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_task_labels_label ON task_labels (label_id);
|
||
|
|
|
||
|
|
-- A reminder is either absolute (remind_at) or relative to the task's due time
|
||
|
|
-- (offset_min, negative = before). Exactly one of the two.
|
||
|
|
CREATE TABLE reminders (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
remind_at INTEGER,
|
||
|
|
offset_min INTEGER,
|
||
|
|
sent_at INTEGER,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
CHECK ((remind_at IS NULL) <> (offset_min IS NULL))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_reminders_pending ON reminders (remind_at) WHERE sent_at IS NULL;
|
||
|
|
|
||
|
|
CREATE TABLE comments (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
body TEXT NOT NULL,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
deleted_at INTEGER
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_comments_task ON comments (task_id) WHERE deleted_at IS NULL;
|
||
|
|
|
||
|
|
-- An attachment hangs on a task or on a comment, never both and never neither.
|
||
|
|
CREATE TABLE attachments (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
task_id TEXT REFERENCES tasks (id) ON DELETE CASCADE,
|
||
|
|
comment_id TEXT REFERENCES comments (id) ON DELETE CASCADE,
|
||
|
|
filename TEXT NOT NULL,
|
||
|
|
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||
|
|
size INTEGER NOT NULL,
|
||
|
|
path TEXT NOT NULL,
|
||
|
|
uploaded_by TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
CHECK ((task_id IS NULL) <> (comment_id IS NULL))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_attachments_task ON attachments (task_id) WHERE task_id IS NOT NULL;
|
||
|
|
CREATE INDEX idx_attachments_comment ON attachments (comment_id) WHERE comment_id IS NOT NULL;
|
||
|
|
|
||
|
|
CREATE TABLE activity (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
project_id TEXT NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||
|
|
task_id TEXT REFERENCES tasks (id) ON DELETE SET NULL,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
event TEXT NOT NULL,
|
||
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||
|
|
created_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_activity_project ON activity (project_id, created_at DESC);
|
||
|
|
CREATE INDEX idx_activity_task ON activity (task_id) WHERE task_id IS NOT NULL;
|
||
|
|
|
||
|
|
CREATE TABLE filters (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
query TEXT NOT NULL,
|
||
|
|
color TEXT NOT NULL DEFAULT 'graphite',
|
||
|
|
sort_order REAL NOT NULL DEFAULT 0,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_filters_user ON filters (user_id);
|
||
|
|
|
||
|
|
-- Personal API tokens for scripts and integrations. Hashed, like every other token.
|
||
|
|
CREATE TABLE api_tokens (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
token_hash TEXT NOT NULL UNIQUE,
|
||
|
|
prefix TEXT NOT NULL,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
last_used_at INTEGER,
|
||
|
|
expires_at INTEGER
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX idx_api_tokens_user ON api_tokens (user_id);
|
||
|
|
|
||
|
|
-- Full-text search over tasks. External-content FTS5: the index stores no copy of
|
||
|
|
-- the text, it points back into `tasks` by rowid, and the triggers below keep the
|
||
|
|
-- two in step. `UNINDEXED` columns are carried for filtering without being tokenized.
|
||
|
|
--
|
||
|
|
-- Three indexed columns, not two. FTS5's `remove_diacritics` strips combining marks,
|
||
|
|
-- and in Unicode "ø" and "æ" are not marked letters — they are letters in their own
|
||
|
|
-- right. Diacritic folding alone therefore leaves a Danish user unable to find
|
||
|
|
-- "grøn" by typing "gron". The generated `fold` column on `tasks` carries a
|
||
|
|
-- transliterated copy, and searching all three columns covers every spelling:
|
||
|
|
--
|
||
|
|
-- "grøn" → content hit "gron" → fold hit
|
||
|
|
-- "århus" → content hit "aarhus" → fold hit
|
||
|
|
-- "arhus" → content hit, because remove_diacritics folds the indexed å to a
|
||
|
|
--
|
||
|
|
CREATE VIRTUAL TABLE tasks_fts USING fts5 (
|
||
|
|
content,
|
||
|
|
description,
|
||
|
|
fold,
|
||
|
|
id UNINDEXED,
|
||
|
|
project_id UNINDEXED,
|
||
|
|
content = 'tasks',
|
||
|
|
content_rowid = 'rowid',
|
||
|
|
tokenize = 'unicode61 remove_diacritics 2'
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE TRIGGER tasks_fts_insert AFTER INSERT ON tasks BEGIN
|
||
|
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||
|
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||
|
|
END;
|
||
|
|
|
||
|
|
CREATE TRIGGER tasks_fts_delete AFTER DELETE ON tasks BEGIN
|
||
|
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||
|
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||
|
|
END;
|
||
|
|
|
||
|
|
CREATE TRIGGER tasks_fts_update AFTER UPDATE ON tasks BEGIN
|
||
|
|
INSERT INTO tasks_fts (tasks_fts, rowid, content, description, fold, id, project_id)
|
||
|
|
VALUES ('delete', old.rowid, old.content, old.description, old.fold, old.id, old.project_id);
|
||
|
|
INSERT INTO tasks_fts (rowid, content, description, fold, id, project_id)
|
||
|
|
VALUES (new.rowid, new.content, new.description, new.fold, new.id, new.project_id);
|
||
|
|
END;
|