-- 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);