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,18 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
console.warn('[db] WARNING: DATABASE_URL is not set. Set it in .env (see .env.example).');
|
||||
}
|
||||
|
||||
export const pool = new pg.Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30_000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => console.error('[db] pool error:', err.message));
|
||||
|
||||
export async function query(text, params) {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dotenv/config';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { pool } from './config/db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import workspaceRoutes from './routes/workspaces.js';
|
||||
import pageRoutes from './routes/pages.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import { initSocket } from './socket.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:5173';
|
||||
|
||||
app.use(helmet());
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(
|
||||
cors({
|
||||
origin: CORS_ORIGIN.split(',').map((s) => s.trim()),
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRoutes);
|
||||
app.use('/api/workspaces', workspaceRoutes);
|
||||
app.use('/api/pages', pageRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
res.json({ ok: true, db: 'up' });
|
||||
} catch {
|
||||
res.status(503).json({ ok: false, db: 'down' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[unhandled]', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
const io = initSocket(server, CORS_ORIGIN);
|
||||
app.set('io', io);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[api] listening on :${PORT} (CORS: ${CORS_ORIGIN})`);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
function getSecret() {
|
||||
const s = process.env.JWT_SECRET;
|
||||
if (!s) throw new Error('JWT_SECRET is not set');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function signToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email },
|
||||
getSecret(),
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyToken(token) {
|
||||
return jwt.verify(token, getSecret());
|
||||
}
|
||||
|
||||
/** Express middleware: requires Authorization: Bearer <jwt> */
|
||||
export function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return res.status(401).json({ error: 'Missing bearer token' });
|
||||
}
|
||||
try {
|
||||
const payload = verifyToken(token);
|
||||
req.user = { id: payload.sub, email: payload.email };
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth, signToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
||||
|
||||
function publicUser(row) {
|
||||
return { id: row.id, email: row.email, name: row.name, createdAt: row.created_at };
|
||||
}
|
||||
|
||||
// POST /api/auth/register { email, name, password }
|
||||
router.post('/register', async (req, res) => {
|
||||
const { email = '', name = '', password = '' } = req.body ?? {};
|
||||
if (!EMAIL_RE.test(String(email).toLowerCase())) {
|
||||
return res.status(400).json({ error: 'Valid email required' });
|
||||
}
|
||||
if (String(name).trim().length < 1 || String(name).length > 100) {
|
||||
return res.status(400).json({ error: 'Name must be 1-100 chars' });
|
||||
}
|
||||
if (String(password).length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 chars' });
|
||||
}
|
||||
try {
|
||||
const existing = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
|
||||
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
|
||||
const hash = await bcrypt.hash(String(password), 12);
|
||||
const { rows } = await query(
|
||||
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, created_at',
|
||||
[String(email).toLowerCase(), String(name).trim(), hash],
|
||||
);
|
||||
const token = signToken(rows[0]);
|
||||
return res.status(201).json({ user: publicUser(rows[0]), token });
|
||||
} catch (err) {
|
||||
console.error('[auth/register]', err.message);
|
||||
return res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login { email, password }
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email = '', password = '' } = req.body ?? {};
|
||||
try {
|
||||
const { rows } = await query('SELECT * FROM users WHERE email = $1', [String(email).toLowerCase()]);
|
||||
const user = rows[0];
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const ok = await bcrypt.compare(String(password), user.password_hash);
|
||||
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const token = signToken(user);
|
||||
return res.json({ user: publicUser(user), token });
|
||||
} catch (err) {
|
||||
console.error('[auth/login]', err.message);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', requireAuth, async (req, res) => {
|
||||
const { rows } = await query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({ user: publicUser(rows[0]) });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { query } from '../config/db.js';
|
||||
|
||||
export const ROLES = ['owner', 'admin', 'member', 'viewer'];
|
||||
const RANK = { viewer: 0, member: 1, admin: 2, owner: 3 };
|
||||
|
||||
/** Load membership role of user in workspace, or null. */
|
||||
export async function getRole(workspaceId, userId) {
|
||||
const { rows } = await query(
|
||||
'SELECT role FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
|
||||
[workspaceId, userId],
|
||||
);
|
||||
return rows[0]?.role ?? null;
|
||||
}
|
||||
|
||||
/** Require membership; optionally require minimum role. Attaches req.membership. */
|
||||
export function requireWorkspace(minRole = 'viewer') {
|
||||
return async (req, res, next) => {
|
||||
const workspaceId = req.params.workspaceId || req.params.id || req.body?.workspace_id;
|
||||
if (!workspaceId) return res.status(400).json({ error: 'workspaceId required' });
|
||||
const role = await getRole(workspaceId, req.user.id);
|
||||
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||
if (RANK[role] < RANK[minRole]) {
|
||||
return res.status(403).json({ error: `Requires role ${minRole} or higher` });
|
||||
}
|
||||
req.membership = { workspaceId, role };
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
/** Check page access: user must be member of the page's workspace. */
|
||||
export async function getPageWithWorkspace(pageId) {
|
||||
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { getRole, getPageWithWorkspace } from './guards.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
async function canRead(userId, page) {
|
||||
if (!page) return false;
|
||||
return (await getRole(page.workspace_id, userId)) !== null;
|
||||
}
|
||||
async function canWrite(userId, page) {
|
||||
if (!page) return false;
|
||||
const role = await getRole(page.workspace_id, userId);
|
||||
return role === 'owner' || role === 'admin' || role === 'member';
|
||||
}
|
||||
|
||||
// GET /api/workspaces/:workspaceId/pages — tree list
|
||||
router.get('/by-workspace/:workspaceId', async (req, res) => {
|
||||
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||
const { rows } = await query(
|
||||
`SELECT p.*, u.name AS author_name FROM pages p
|
||||
LEFT JOIN users u ON u.id = p.created_by
|
||||
WHERE p.workspace_id = $1 AND p.is_archived = FALSE
|
||||
ORDER BY COALESCE(p.parent_id::text,'') , p.position, p.updated_at DESC`,
|
||||
[req.params.workspaceId],
|
||||
);
|
||||
return res.json({ pages: rows, role });
|
||||
});
|
||||
|
||||
// POST /api/pages { workspace_id, parent_id?, title? }
|
||||
router.post('/', async (req, res) => {
|
||||
const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {};
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
const role = await getRole(workspace_id, req.user.id);
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
||||
return res.status(403).json({ error: 'Viewers cannot create pages' });
|
||||
}
|
||||
if (parent_id) {
|
||||
const parent = await getPageWithWorkspace(parent_id);
|
||||
if (!parent || parent.workspace_id !== workspace_id) {
|
||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
}
|
||||
}
|
||||
const { rows } = await query(
|
||||
`INSERT INTO pages (workspace_id, parent_id, title, created_by)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
|
||||
);
|
||||
return res.status(201).json({ page: rows[0] });
|
||||
});
|
||||
|
||||
// GET /api/pages/:id
|
||||
router.get('/:id', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.id);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canRead(req.user.id, page))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const { rows: comments } = await query(
|
||||
`SELECT c.*, u.name AS author_name FROM comments c
|
||||
LEFT JOIN users u ON u.id = c.author_id
|
||||
WHERE c.page_id = $1 ORDER BY c.created_at`,
|
||||
[page.id],
|
||||
);
|
||||
return res.json({ page, comments });
|
||||
});
|
||||
|
||||
// PATCH /api/pages/:id { title?, icon?, cover?, content_json?, content_html?, position?, parent_id?, is_archived? }
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.id);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
|
||||
const { title, icon, cover, content_json, content_html, position, parent_id, is_archived } = req.body ?? {};
|
||||
if (parent_id !== undefined && parent_id !== null) {
|
||||
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
|
||||
const parent = await getPageWithWorkspace(parent_id);
|
||||
if (!parent || parent.workspace_id !== page.workspace_id) {
|
||||
return res.status(400).json({ error: 'Invalid parent_id' });
|
||||
}
|
||||
}
|
||||
const { rows } = await query(
|
||||
`UPDATE pages SET
|
||||
title = COALESCE($1, title),
|
||||
icon = COALESCE($2, icon),
|
||||
cover = COALESCE($3, cover),
|
||||
content_json = COALESCE($4, content_json),
|
||||
content_html = COALESCE($5, content_html),
|
||||
position = COALESCE($6, position),
|
||||
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
|
||||
is_archived = COALESCE($9, is_archived)
|
||||
WHERE id = $10 RETURNING *`,
|
||||
[
|
||||
title !== undefined ? String(title).slice(0, 300) : null,
|
||||
icon !== undefined ? String(icon).slice(0, 20) : null,
|
||||
cover !== undefined ? String(cover).slice(0, 500) : null,
|
||||
content_json !== undefined ? JSON.stringify(content_json).slice(0, 2_000_000) : null,
|
||||
content_html !== undefined ? String(content_html).slice(0, 2_000_000) : null,
|
||||
position !== undefined ? Number(position) : null,
|
||||
parent_id !== undefined ? parent_id : null,
|
||||
parent_id !== undefined,
|
||||
is_archived !== undefined ? Boolean(is_archived) : null,
|
||||
page.id,
|
||||
],
|
||||
);
|
||||
const updated = rows[0];
|
||||
// Broadcast to realtime subscribers
|
||||
req.app.get('io')?.to(`page:${page.id}`).emit('page:updated', { page: updated });
|
||||
return res.json({ page: updated });
|
||||
});
|
||||
|
||||
// DELETE /api/pages/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.id);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot delete' });
|
||||
await query('DELETE FROM pages WHERE id = $1', [page.id]);
|
||||
req.app.get('io')?.to(`page:${page.id}`).emit('page:deleted', { id: page.id });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/pages/search?q=&workspace_id=
|
||||
router.get('/search/query', async (req, res) => {
|
||||
const { q = '', workspace_id } = req.query;
|
||||
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
|
||||
if (!(await getRole(workspace_id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const { rows } = await query(
|
||||
`SELECT id, title, parent_id, updated_at FROM pages
|
||||
WHERE workspace_id = $1 AND is_archived = FALSE AND title ILIKE $2
|
||||
ORDER BY updated_at DESC LIMIT 30`,
|
||||
[workspace_id, `%${String(q).slice(0, 100)}%`],
|
||||
);
|
||||
return res.json({ results: rows });
|
||||
});
|
||||
|
||||
// POST /api/pages/:id/comments { body }
|
||||
router.post('/:id/comments', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.id);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
if (!(await canRead(req.user.id, page))) return res.status(403).json({ error: 'Forbidden' });
|
||||
const { body = '' } = req.body ?? {};
|
||||
if (String(body).trim().length < 1 || String(body).length > 5000) {
|
||||
return res.status(400).json({ error: 'Body must be 1-5000 chars' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
'INSERT INTO comments (page_id, author_id, body) VALUES ($1,$2,$3) RETURNING *',
|
||||
[page.id, req.user.id, String(body).trim()],
|
||||
);
|
||||
req.app.get('io')?.to(`page:${page.id}`).emit('comment:added', { comment: rows[0] });
|
||||
return res.status(201).json({ comment: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/pages/:pageId/comments/:commentId (author or admin+)
|
||||
router.delete('/:pageId/comments/:commentId', async (req, res) => {
|
||||
const page = await getPageWithWorkspace(req.params.pageId);
|
||||
if (!page) return res.status(404).json({ error: 'Not found' });
|
||||
const { rows } = await query('SELECT * FROM comments WHERE id = $1 AND page_id = $2', [
|
||||
req.params.commentId,
|
||||
page.id,
|
||||
]);
|
||||
const comment = rows[0];
|
||||
if (!comment) return res.status(404).json({ error: 'Comment not found' });
|
||||
const role = await getRole(page.workspace_id, req.user.id);
|
||||
const isAuthor = comment.author_id === req.user.id;
|
||||
if (!isAuthor && role !== 'owner' && role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
await query('DELETE FROM comments WHERE id = $1', [comment.id]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/users/search?email= — find user by email (for invites)
|
||||
router.get('/search', async (req, res) => {
|
||||
const { email = '' } = req.query;
|
||||
if (!email) return res.status(400).json({ error: 'email required' });
|
||||
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [
|
||||
String(email).toLowerCase(),
|
||||
]);
|
||||
return res.json({ user: rows[0] || null });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Router } from 'express';
|
||||
import { query } from '../config/db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { getRole, requireWorkspace, ROLES } from './guards.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /api/workspaces — list my workspaces
|
||||
router.get('/', async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT w.id, w.name, w.owner_id, w.created_at, w.updated_at, m.role
|
||||
FROM workspaces w JOIN workspace_members m ON m.workspace_id = w.id
|
||||
WHERE m.user_id = $1 ORDER BY w.updated_at DESC`,
|
||||
[req.user.id],
|
||||
);
|
||||
return res.json({ workspaces: rows });
|
||||
});
|
||||
|
||||
// POST /api/workspaces { name }
|
||||
router.post('/', async (req, res) => {
|
||||
const { name = '' } = req.body ?? {};
|
||||
if (String(name).trim().length < 1 || String(name).length > 120) {
|
||||
return res.status(400).json({ error: 'Name must be 1-120 chars' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
'INSERT INTO workspaces (name, owner_id) VALUES ($1,$2) RETURNING *',
|
||||
[String(name).trim(), req.user.id],
|
||||
);
|
||||
return res.status(201).json({ workspace: rows[0] });
|
||||
});
|
||||
|
||||
// GET /api/workspaces/:id
|
||||
router.get('/:id', requireWorkspace('viewer'), async (req, res) => {
|
||||
const { rows } = await query('SELECT * FROM workspaces WHERE id = $1', [req.params.id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'Not found' });
|
||||
return res.json({ workspace: { ...rows[0], role: req.membership.role } });
|
||||
});
|
||||
|
||||
// PATCH /api/workspaces/:id { name } (admin+)
|
||||
router.patch('/:id', requireWorkspace('admin'), async (req, res) => {
|
||||
const { name } = req.body ?? {};
|
||||
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 120)) {
|
||||
return res.status(400).json({ error: 'Name must be 1-120 chars' });
|
||||
}
|
||||
const { rows } = await query(
|
||||
'UPDATE workspaces SET name = COALESCE($1, name) WHERE id = $2 RETURNING *',
|
||||
[name !== undefined ? String(name).trim() : null, req.params.id],
|
||||
);
|
||||
return res.json({ workspace: rows[0] });
|
||||
});
|
||||
|
||||
// DELETE /api/workspaces/:id (owner only)
|
||||
router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
|
||||
await query('DELETE FROM workspaces WHERE id = $1', [req.params.id]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// GET /api/workspaces/:id/members
|
||||
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
|
||||
const { rows } = await query(
|
||||
`SELECT u.id, u.email, u.name, m.role, m.created_at
|
||||
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.workspace_id = $1 ORDER BY m.created_at`,
|
||||
[req.params.id],
|
||||
);
|
||||
return res.json({ members: rows });
|
||||
});
|
||||
|
||||
// POST /api/workspaces/:id/members { email, role } (admin+; cannot grant owner)
|
||||
router.post('/:id/members', requireWorkspace('admin'), async (req, res) => {
|
||||
const { email = '', role = 'member' } = req.body ?? {};
|
||||
if (!ROLES.includes(role) || role === 'owner') {
|
||||
return res.status(400).json({ error: 'Role must be admin, member or viewer' });
|
||||
}
|
||||
const { rows: users } = await query('SELECT id FROM users WHERE email = $1', [String(email).toLowerCase()]);
|
||||
if (!users[0]) return res.status(404).json({ error: 'No user with that email (they must register first)' });
|
||||
await query(
|
||||
`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,$3)
|
||||
ON CONFLICT (workspace_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
[req.params.id, users[0].id, role],
|
||||
);
|
||||
return res.status(201).json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
|
||||
router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
|
||||
const { role } = req.body ?? {};
|
||||
if (!ROLES.includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
if (role === 'owner') return res.status(400).json({ error: 'Ownership transfer is not supported in MVP' });
|
||||
const current = await getRole(req.params.id, req.params.userId);
|
||||
if (current === 'owner') return res.status(400).json({ error: 'Cannot change owner role' });
|
||||
await query(
|
||||
'UPDATE workspace_members SET role = $1 WHERE workspace_id = $2 AND user_id = $3',
|
||||
[role, req.params.id, req.params.userId],
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /api/workspaces/:id/members/:userId (admin+; owner cannot be removed)
|
||||
router.delete('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
|
||||
const current = await getRole(req.params.id, req.params.userId);
|
||||
if (current === 'owner') return res.status(400).json({ error: 'Cannot remove owner' });
|
||||
await query('DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2', [
|
||||
req.params.id,
|
||||
req.params.userId,
|
||||
]);
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Server } from 'socket.io';
|
||||
import { verifyToken } from '../middleware/auth.js';
|
||||
import { query } from '../config/db.js';
|
||||
|
||||
/**
|
||||
* Real-time collaboration protocol (see docs/API.md):
|
||||
* - Client connects with auth: { token }
|
||||
* - Emits page:join { pageId } -> joined room `page:<id>`, receives presence list
|
||||
* - Emits page:edit { pageId, content_json, content_html } (debounced client-side)
|
||||
* -> persisted + broadcast as page:updated to other members
|
||||
* - Emits typing { pageId, name } -> broadcast typing indicator
|
||||
* - Server tracks presence per room and emits presence:update on join/leave.
|
||||
*/
|
||||
export function initSocket(httpServer, corsOrigin) {
|
||||
const io = new Server(httpServer, {
|
||||
cors: { origin: corsOrigin?.split(',').map((s) => s.trim()) || true },
|
||||
});
|
||||
|
||||
// JWT auth handshake
|
||||
io.use((socket, next) => {
|
||||
try {
|
||||
const token = socket.handshake.auth?.token;
|
||||
if (!token) return next(new Error('Missing token'));
|
||||
const payload = verifyToken(token);
|
||||
socket.data.user = { id: payload.sub, email: payload.email };
|
||||
return next();
|
||||
} catch {
|
||||
return next(new Error('Invalid token'));
|
||||
}
|
||||
});
|
||||
|
||||
const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
|
||||
|
||||
function emitPresence(room) {
|
||||
const members = [...(presence.get(room)?.values() ?? [])];
|
||||
io.to(room).emit('presence:update', { members });
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
|
||||
socket.on('page:join', async ({ pageId }) => {
|
||||
try {
|
||||
if (!pageId) return;
|
||||
const { rows } = await query(
|
||||
`SELECT p.id, p.workspace_id FROM pages p WHERE p.id = $1`,
|
||||
[pageId],
|
||||
);
|
||||
const page = rows[0];
|
||||
if (!page) return socket.emit('error:message', { error: 'Page not found' });
|
||||
const { rows: m } = await query(
|
||||
'SELECT u.name, u.email FROM workspace_members m JOIN users u ON u.id = m.user_id WHERE m.workspace_id = $1 AND m.user_id = $2',
|
||||
[page.workspace_id, user.id],
|
||||
);
|
||||
if (!m[0]) return socket.emit('error:message', { error: 'Forbidden' });
|
||||
|
||||
const room = `page:${pageId}`;
|
||||
socket.join(room);
|
||||
if (!presence.has(room)) presence.set(room, new Map());
|
||||
presence.get(room).set(socket.id, { userId: user.id, email: m[0].email, name: m[0].name });
|
||||
emitPresence(room);
|
||||
} catch (err) {
|
||||
socket.emit('error:message', { error: 'Join failed' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('page:edit', async ({ pageId, content_json, content_html }) => {
|
||||
try {
|
||||
if (!pageId) return;
|
||||
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
|
||||
const page = rows[0];
|
||||
if (!page) return;
|
||||
const { rows: m } = await query(
|
||||
'SELECT role FROM workspace_members WHERE workspace_id = $1 AND user_id = $2',
|
||||
[page.workspace_id, user.id],
|
||||
);
|
||||
const role = m[0]?.role;
|
||||
if (role !== 'owner' && role !== 'admin' && role !== 'member') return; // viewers blocked
|
||||
const { rows: updated } = await query(
|
||||
'UPDATE pages SET content_json = $1, content_html = $2 WHERE id = $3 RETURNING *',
|
||||
[
|
||||
JSON.stringify(content_json ?? {}).slice(0, 2_000_000),
|
||||
String(content_html ?? '').slice(0, 2_000_000),
|
||||
pageId,
|
||||
],
|
||||
);
|
||||
socket.to(`page:${pageId}`).emit('page:updated', { page: updated[0], from: user.id });
|
||||
} catch (err) {
|
||||
console.error('[socket page:edit]', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('typing', ({ pageId, name }) => {
|
||||
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name });
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
for (const [room, members] of presence) {
|
||||
if (members.delete(socket.id)) emitPresence(room);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
Reference in New Issue
Block a user