feat(backend): Express + Socket.IO API with PostgreSQL
- Auth: register/login/me with bcrypt (12 rounds) and JWT bearer tokens - Workspaces: CRUD, role-based members (owner/admin/member/viewer), invite by email - Pages: nested hierarchy, TipTap JSON+HTML content, archive, search, comments - Realtime: Socket.IO rooms per page with JWT handshake, live edits, presence, typing - DB schema with triggers (owner membership, updated_at) and migrate script - Helmet, CORS allow-list, rate limiting, /api/health with DB check - Dockerfile (migrate-on-boot) for compose/production
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { pool } from '../config/db.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function migrate() {
|
||||
const schemaPath = path.join(__dirname, 'schema.sql');
|
||||
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||
// pg_trgm may not exist on all hosts; try to enable but don't fail.
|
||||
try {
|
||||
await pool.query('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
} catch { /* ignore */ }
|
||||
await pool.query(sql);
|
||||
console.log('[migrate] schema applied');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('[migrate] failed:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
-- 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);
|
||||
|
||||
-- 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();
|
||||
Reference in New Issue
Block a user