-- openStack schema (PostgreSQL 14+) -- Apply with: psql $DATABASE_URL < schema.sql -- Idempotent: safe to re-run (CREATE TABLE IF NOT EXISTS + DO blocks). CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Users CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE CHECK (email ~* '^[^@\s]+@[^@\s]+\.[^@\s]+$'), name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100), password_hash TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Workspaces CREATE TABLE IF NOT EXISTS workspaces ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120), owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Membership (role-based collaboration) CREATE TABLE IF NOT EXISTS workspace_members ( workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','admin','member','viewer')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (workspace_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id); -- Pages (nested via parent_id) CREATE TABLE IF NOT EXISTS pages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, parent_id UUID REFERENCES pages(id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT 'Untitled' CHECK (char_length(title) BETWEEN 1 AND 300), icon TEXT NOT NULL DEFAULT '', cover TEXT NOT NULL DEFAULT '', content_json JSONB NOT NULL DEFAULT '{}'::jsonb, content_html TEXT NOT NULL DEFAULT '', position DOUBLE PRECISION NOT NULL DEFAULT 0, is_archived BOOLEAN NOT NULL DEFAULT FALSE, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (parent_id IS NULL OR parent_id <> id) ); CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id); CREATE INDEX IF NOT EXISTS idx_pages_parent ON pages(parent_id); CREATE INDEX IF NOT EXISTS idx_pages_title_trgm ON pages USING gin (title gin_trgm_ops); -- pg_trgm is optional; ignore failure on minimal installs: -- CREATE EXTENSION IF NOT EXISTS pg_trgm; -- Comments CREATE TABLE IF NOT EXISTS comments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE, author_id UUID REFERENCES users(id) ON DELETE SET NULL, body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 5000), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_comments_page ON comments(page_id); -- App settings (first-run installer state, feature flags) CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Global admin flag (first user created via web installer) ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; -- Profile / appearance / locale settings (edited via settings window) ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT ''; ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en'; ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'; ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT ''; ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE; -- Books: independent containers that hold pages (collapsible in sidebar) CREATE TABLE IF NOT EXISTS books ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200), icon TEXT NOT NULL DEFAULT '📚', position DOUBLE PRECISION NOT NULL DEFAULT 0, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id); -- Pages can optionally live inside a book (book_id NULL = top-level page) ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id); -- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar) CREATE TABLE IF NOT EXISTS tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, book_id UUID REFERENCES books(id) ON DELETE SET NULL, page_id UUID REFERENCES pages(id) ON DELETE SET NULL, title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300), status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')), priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')), due_at TIMESTAMPTZ NULL, position DOUBLE PRECISION NOT NULL DEFAULT 0, created_by UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id); CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at); -- Time tracking entries (open entry = running timer, ended_at IS NULL) CREATE TABLE IF NOT EXISTS time_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, book_id UUID REFERENCES books(id) ON DELETE SET NULL, page_id UUID REFERENCES pages(id) ON DELETE SET NULL, description TEXT NOT NULL DEFAULT '', started_at TIMESTAMPTZ NOT NULL DEFAULT now(), ended_at TIMESTAMPTZ NULL, duration_seconds INTEGER NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id); -- Ensure workspace owner is always a member with role=owner CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ BEGIN INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (NEW.id, NEW.owner_id, 'owner') ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = 'owner'; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_ensure_owner ON workspaces; CREATE TRIGGER trg_ensure_owner AFTER INSERT ON workspaces FOR EACH ROW EXECUTE FUNCTION ensure_owner_membership(); -- Auto-update updated_at CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_users_touch ON users; CREATE TRIGGER trg_users_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); DROP TRIGGER IF EXISTS trg_workspaces_touch ON workspaces; CREATE TRIGGER trg_workspaces_touch BEFORE UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); DROP TRIGGER IF EXISTS trg_pages_touch ON pages; CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); DROP TRIGGER IF EXISTS trg_books_touch ON books; CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks; CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION touch_updated_at();