- Schema: books, tasks (book/page links, status, priority, due_at), time_entries (open entry = running timer), user profile columns (avatar, locale, theme, bg_image, bg_blur) - Books CRUD; pages can live in a book (book_id, validated same-workspace) - Tasks CRUD with status filter and due_at range query for the calendar - Time API: start (409 if running), stop (computes duration), list with open timer, update, delete - Users: fuzzy discovery for invites, PATCH /me profile/appearance, password change; members list now includes avatars - Admin API (is_admin guard): overview counts, user list, grant/revoke admin, delete user (self-demote/delete blocked)
178 lines
7.6 KiB
PL/PgSQL
178 lines
7.6 KiB
PL/PgSQL
-- 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();
|