diff --git a/migrations/001_create_users_table.sql b/migrations/001_create_users_table.sql deleted file mode 100644 index 1b80c9b..0000000 --- a/migrations/001_create_users_table.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL UNIQUE COLLATE NOCASE, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..fff963b --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,112 @@ +-- Users. A magic-link email is the only way to sign in (see EmailVerifier / +-- AuthController::requestLoginLink) -- there is no password. Passkeys +-- (passkeys / webauthn_challenges below) are the only alternative. +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + -- Null until the address is verified -- opening a magic link both signs + -- the user in and, the first time, sets this. + email_verified_at TEXT NULL, + -- When the last verification / email-change link was sent, for the + -- once-per-minute resend throttle. + verification_email_sent_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +-- Projects. Each belongs to exactly one owner. +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + title TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects (owner_id); + +-- Project-specific card statuses ("To do" / "Doing" / "Done" by default -- +-- see CardStatusRepository::seedDefaults -- then user-managed from there via +-- the project configuration view). +CREATE TABLE IF NOT EXISTS card_statuses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position + ON card_statuses (project_id, position); + +-- Cards. A card either sits in its owner's global inbox (project_id AND +-- status_id both NULL) or belongs to exactly one project with a status in it +-- (both set) -- the CHECK below enforces that pairing, never one without the +-- other. `position` is a dense 0..n-1 rank within a "column": the cards +-- sharing an (owner_id, project_id, status_id). +CREATE TABLE IF NOT EXISTS cards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + project_id INTEGER NULL REFERENCES projects (id) ON DELETE CASCADE, + -- RESTRICT, not SET NULL: a project card must always have a status (see + -- the CHECK below), so a status can only be deleted once its cards are + -- reassigned elsewhere first (see CardStatusController::destroy). + status_id INTEGER NULL REFERENCES card_statuses (id) ON DELETE RESTRICT, + text TEXT NOT NULL, + complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + CHECK ((project_id IS NULL) = (status_id IS NULL)) +); + +CREATE INDEX IF NOT EXISTS idx_cards_owner_project_status_position + ON cards (owner_id, project_id, status_id, position); +CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id); + +-- Magic-link tokens for verifying an email address. `new_email` is null for a +-- plain "verify your current address" link, or the requested address for a +-- deferred email change (applied only when the link is opened). +CREATE TABLE IF NOT EXISTS email_verifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + new_email TEXT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id); + +-- Passkeys (WebAuthn discoverable credentials): an alternative to the email +-- magic link. A user may register several (one per device/authenticator). +CREATE TABLE IF NOT EXISTS passkeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + label TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + last_used_at TEXT NULL +); + +CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys (user_id); + +-- Short-lived, single-use WebAuthn challenges bridging the "options" and +-- "verify" calls of both the registration and login ceremonies. user_id is +-- set for a registration (tied to the signed-in caller) and NULL for a login +-- attempt, since who's logging in isn't known until the credential comes back. +CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, + purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')), + challenge TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at); diff --git a/migrations/002_add_email_verification_to_users.sql b/migrations/002_add_email_verification_to_users.sql deleted file mode 100644 index 0c2aed3..0000000 --- a/migrations/002_add_email_verification_to_users.sql +++ /dev/null @@ -1,3 +0,0 @@ --- Null means the email address has not been verified yet. New accounts start --- unverified; a later stage will add the endpoint that sets this timestamp. -ALTER TABLE users ADD COLUMN email_verified_at TEXT NULL; diff --git a/migrations/003_create_projects_table.sql b/migrations/003_create_projects_table.sql deleted file mode 100644 index 3991338..0000000 --- a/migrations/003_create_projects_table.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS projects ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects (owner_id); diff --git a/migrations/004_create_cards_table.sql b/migrations/004_create_cards_table.sql deleted file mode 100644 index 8e64175..0000000 --- a/migrations/004_create_cards_table.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - text TEXT NOT NULL, - complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), - position INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_cards_project_position ON cards (project_id, position); diff --git a/migrations/005_add_email_verification_tokens.sql b/migrations/005_add_email_verification_tokens.sql deleted file mode 100644 index dd0968c..0000000 --- a/migrations/005_add_email_verification_tokens.sql +++ /dev/null @@ -1,18 +0,0 @@ --- Magic-link tokens for verifying an email address. `new_email` is null for a --- plain "verify your current address" link, or the requested address for a --- deferred email change (applied only when the link is opened). -CREATE TABLE IF NOT EXISTS email_verifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - new_email TEXT NULL, - expires_at TEXT NOT NULL, - consumed_at TEXT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_email_verifications_user ON email_verifications (user_id); - --- When the last verification / change email was sent to this user, for the --- once-per-minute resend throttle. -ALTER TABLE users ADD COLUMN verification_email_sent_at TEXT NULL; diff --git a/migrations/006_add_card_statuses.sql b/migrations/006_add_card_statuses.sql deleted file mode 100644 index e386890..0000000 --- a/migrations/006_add_card_statuses.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Project-specific card statuses. Every project gets a "To do" / "Doing" / --- "Done" set when it is created (see CardStatusRepository::seedDefaults); the --- backfill below covers projects that already existed. -CREATE TABLE IF NOT EXISTS card_statuses ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_id INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, - name TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_card_statuses_project_position - ON card_statuses (project_id, position); - --- Nullable, and left NULL for existing cards: a card without a status sits in --- the project "inbox" until the user gives it one. Losing a status (a deleted --- status row) clears the link rather than removing the card. -ALTER TABLE cards - ADD COLUMN status_id INTEGER REFERENCES card_statuses (id) ON DELETE SET NULL; - -CREATE INDEX IF NOT EXISTS idx_cards_status ON cards (status_id); - --- Backfill: give every pre-existing project the default status set. -INSERT INTO card_statuses (project_id, name, position) -SELECT p.id, d.name, d.position -FROM projects p -CROSS JOIN ( - SELECT 'To do' AS name, 0 AS position - UNION ALL SELECT 'Doing', 1 - UNION ALL SELECT 'Done', 2 -) d; diff --git a/migrations/007_scope_card_position_to_column.sql b/migrations/007_scope_card_position_to_column.sql deleted file mode 100644 index 6ea3140..0000000 --- a/migrations/007_scope_card_position_to_column.sql +++ /dev/null @@ -1,24 +0,0 @@ --- `cards.position` was a project-wide sort key. It is now a rank *within a --- column* — the group of cards sharing a (project_id, status_id). The inbox --- (status_id IS NULL) is its own column. The "all tasks" view no longer uses --- position at all (it sorts alphabetically); ordering is a per-column concern --- driven by PUT /api/projects/{id}/cards/order. - -DROP INDEX IF EXISTS idx_cards_project_position; - -CREATE INDEX IF NOT EXISTS idx_cards_project_status_position - ON cards (project_id, status_id, position); - --- Re-pack every existing column to a dense 0..n-1, preserving current order. --- The CTE is evaluated against the pre-update snapshot, so this is safe despite --- writing the same table. -WITH ranked (id, rk) AS ( - SELECT id, - row_number() OVER ( - PARTITION BY project_id, status_id - ORDER BY position, id - ) - 1 - FROM cards -) -UPDATE cards -SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id); diff --git a/migrations/008_drop_user_passwords.sql b/migrations/008_drop_user_passwords.sql deleted file mode 100644 index 940f9ba..0000000 --- a/migrations/008_drop_user_passwords.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Passwordless auth: the only way in is a magic link emailed to an address --- (see EmailVerifier / AuthController::requestLoginLink). Opening a link both --- creates the account (if it's new) and signs the user in, so an authenticated --- session now always implies a verified email -- there is no more --- authenticated-but-unverified state to nag about. -ALTER TABLE users DROP COLUMN password_hash; diff --git a/migrations/009_global_inbox.sql b/migrations/009_global_inbox.sql deleted file mode 100644 index a7ad1d4..0000000 --- a/migrations/009_global_inbox.sql +++ /dev/null @@ -1,58 +0,0 @@ --- The inbox becomes global to a user rather than per-project: a card now --- either belongs to nobody's project (project_id AND status_id both NULL -- --- sitting in that user's inbox) or to exactly one project with a status in it --- (both set). Cards also gain a direct owner_id, since inbox cards have no --- project to derive ownership from. --- --- SQLite can't relax an existing NOT NULL / add a CHECK to a live column, so --- the table is rebuilt. - -CREATE TABLE cards_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - project_id INTEGER NULL REFERENCES projects (id) ON DELETE CASCADE, - -- Was ON DELETE SET NULL; now that a project card must have a status - -- (the CHECK below), silently nulling status_id on a deleted status would - -- leave project_id orphaned without it. There is no status-delete - -- endpoint yet, so this is only a safety net. - status_id INTEGER NULL REFERENCES card_statuses (id) ON DELETE RESTRICT, - text TEXT NOT NULL, - complete INTEGER NOT NULL DEFAULT 0 CHECK (complete IN (0, 1)), - position INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - CHECK ((project_id IS NULL) = (status_id IS NULL)) -); - --- Existing cards with no status were sitting in their project's old --- per-project inbox column; that concept is gone, so they move to the (now --- global, per-owner) inbox -- project_id cleared alongside status_id. -INSERT INTO cards_new (id, owner_id, project_id, status_id, text, complete, position, created_at, updated_at) -SELECT c.id, - p.owner_id, - CASE WHEN c.status_id IS NULL THEN NULL ELSE c.project_id END, - c.status_id, - c.text, c.complete, c.position, c.created_at, c.updated_at -FROM cards c -JOIN projects p ON p.id = c.project_id; - -DROP TABLE cards; -ALTER TABLE cards_new RENAME TO cards; - -CREATE INDEX idx_cards_owner_project_status_position - ON cards (owner_id, project_id, status_id, position); -CREATE INDEX idx_cards_status ON cards (status_id); - --- Re-pack every column -- including each owner's inbox -- to a dense 0..n-1, --- preserving relative order. Safe despite writing the table it reads: the CTE --- is evaluated against the pre-update snapshot. -WITH ranked (id, rk) AS ( - SELECT id, - row_number() OVER ( - PARTITION BY owner_id, project_id, status_id - ORDER BY position, id - ) - 1 - FROM cards -) -UPDATE cards -SET position = (SELECT rk FROM ranked WHERE ranked.id = cards.id); diff --git a/migrations/010_add_passkeys.sql b/migrations/010_add_passkeys.sql deleted file mode 100644 index 3b23081..0000000 --- a/migrations/010_add_passkeys.sql +++ /dev/null @@ -1,30 +0,0 @@ --- Passkeys (WebAuthn discoverable credentials): an alternative to the email --- magic link. A user may register several (one per device/authenticator). -CREATE TABLE IF NOT EXISTS passkeys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, - credential_id TEXT NOT NULL UNIQUE, - public_key TEXT NOT NULL, - sign_count INTEGER NOT NULL DEFAULT 0, - label TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - last_used_at TEXT NULL -); - -CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys (user_id); - --- Short-lived, single-use WebAuthn challenges bridging the "options" and --- "verify" calls of both the registration and login ceremonies. user_id is --- set for a registration (tied to the signed-in caller) and NULL for a login --- attempt, since who's logging in isn't known until the credential comes back. -CREATE TABLE IF NOT EXISTS webauthn_challenges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NULL REFERENCES users (id) ON DELETE CASCADE, - purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login')), - challenge TEXT NOT NULL, - expires_at TEXT NOT NULL, - consumed_at TEXT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - -CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at); diff --git a/migrations/011_drop_project_description.sql b/migrations/011_drop_project_description.sql deleted file mode 100644 index fdfdf21..0000000 --- a/migrations/011_drop_project_description.sql +++ /dev/null @@ -1,3 +0,0 @@ --- The description field never got a UI home on the frontend and isn't used --- anywhere; drop it rather than carry an unused column. -ALTER TABLE projects DROP COLUMN description;