diff --git a/server/db.js b/server/db.js index ff9b754..bc30f41 100644 --- a/server/db.js +++ b/server/db.js @@ -42,6 +42,19 @@ export async function initDb() { `) await db.query(`ALTER TABLE templates ALTER COLUMN template_key SET NOT NULL`) + await db.query(` + CREATE TABLE IF NOT EXISTS folders ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + parent_id text REFERENCES folders(id) ON DELETE CASCADE, + created_by text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `) + await db.query(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS folder_id text REFERENCES folders(id) ON DELETE SET NULL`) + await db.query(` CREATE TABLE IF NOT EXISTS template_versions ( id text PRIMARY KEY, @@ -118,6 +131,18 @@ export async function initDb() { CREATE INDEX IF NOT EXISTS templates_tenant_updated_idx ON templates (tenant_id, updated_at DESC) `) + await db.query(` + CREATE INDEX IF NOT EXISTS templates_folder_idx + ON templates (tenant_id, folder_id, updated_at DESC) + `) + await db.query(` + CREATE INDEX IF NOT EXISTS folders_parent_idx + ON folders (tenant_id, parent_id, name) + `) + await db.query(` + CREATE UNIQUE INDEX IF NOT EXISTS folders_unique_sibling_name_idx + ON folders (tenant_id, COALESCE(parent_id, ''), lower(name)) + `) await db.query(` CREATE UNIQUE INDEX IF NOT EXISTS templates_tenant_key_idx ON templates (tenant_id, template_key) diff --git a/server/index.js b/server/index.js index ce5e2a3..0cf3c45 100644 --- a/server/index.js +++ b/server/index.js @@ -356,9 +356,155 @@ app.post( } ) +app.get('/api/folders', async (_req, res) => { + const { rows } = await getPool().query( + `SELECT + f.id, + f.name, + f.parent_id, + f.created_at, + f.updated_at, + COUNT(t.id)::int AS template_count + FROM folders f + LEFT JOIN templates t + ON t.tenant_id=f.tenant_id + AND t.folder_id=f.id + WHERE f.tenant_id=$1 + GROUP BY f.id + ORDER BY lower(f.name), f.created_at`, + [tenantId] + ) + res.setHeader('Cache-Control', 'no-store') + res.json(rows) +}) + +app.post('/api/folders', async (req, res) => { + const name = String(req.body?.name || '').trim().slice(0, 120) + const parentId = req.body?.parentId ? String(req.body.parentId) : null + if (!name) return res.status(400).json({ error: 'name_required' }) + + if (parentId) { + const parent = await getPool().query( + 'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2', + [parentId, tenantId] + ) + if (!parent.rowCount) return res.status(400).json({ error: 'invalid_parent_folder' }) + } + + try { + const { rows } = await getPool().query( + `INSERT INTO folders (id,tenant_id,name,parent_id,created_by) + VALUES ($1,$2,$3,$4,$5) + RETURNING id,name,parent_id,created_at,updated_at,0::int AS template_count`, + [crypto.randomUUID(), tenantId, name, parentId, userId(req)] + ) + res.status(201).json(rows[0]) + } catch (error) { + if (error?.code === '23505') return res.status(409).json({ error: 'folder_name_exists' }) + throw error + } +}) + +app.patch('/api/folders/:id', async (req, res) => { + const current = await getPool().query( + 'SELECT * FROM folders WHERE id=$1 AND tenant_id=$2', + [req.params.id, tenantId] + ) + if (!current.rows[0]) return res.status(404).json({ error: 'not_found' }) + + const name = req.body?.name === undefined + ? current.rows[0].name + : String(req.body.name || '').trim().slice(0, 120) + const parentId = req.body?.parentId === undefined + ? current.rows[0].parent_id + : (req.body.parentId ? String(req.body.parentId) : null) + + if (!name) return res.status(400).json({ error: 'name_required' }) + if (parentId === req.params.id) return res.status(400).json({ error: 'folder_cycle' }) + + if (parentId) { + const ancestry = await getPool().query( + `WITH RECURSIVE descendants AS ( + SELECT id FROM folders WHERE id=$1 AND tenant_id=$2 + UNION ALL + SELECT f.id + FROM folders f + JOIN descendants d ON f.parent_id=d.id + WHERE f.tenant_id=$2 + ) + SELECT 1 FROM descendants WHERE id=$3 LIMIT 1`, + [req.params.id, tenantId, parentId] + ) + if (ancestry.rowCount) return res.status(400).json({ error: 'folder_cycle' }) + + const parent = await getPool().query( + 'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2', + [parentId, tenantId] + ) + if (!parent.rowCount) return res.status(400).json({ error: 'invalid_parent_folder' }) + } + + try { + const { rows } = await getPool().query( + `UPDATE folders + SET name=$1,parent_id=$2,updated_at=now() + WHERE id=$3 AND tenant_id=$4 + RETURNING id,name,parent_id,created_at,updated_at`, + [name, parentId, req.params.id, tenantId] + ) + res.json(rows[0]) + } catch (error) { + if (error?.code === '23505') return res.status(409).json({ error: 'folder_name_exists' }) + throw error + } +}) + +app.delete('/api/folders/:id', async (req, res) => { + const folder = await getPool().query( + 'SELECT parent_id FROM folders WHERE id=$1 AND tenant_id=$2', + [req.params.id, tenantId] + ) + if (!folder.rows[0]) return res.status(404).json({ error: 'not_found' }) + + const contents = await getPool().query( + `SELECT + EXISTS(SELECT 1 FROM folders WHERE tenant_id=$1 AND parent_id=$2) AS has_folders, + EXISTS(SELECT 1 FROM templates WHERE tenant_id=$1 AND folder_id=$2) AS has_templates`, + [tenantId, req.params.id] + ) + if (contents.rows[0]?.has_folders || contents.rows[0]?.has_templates) { + return res.status(409).json({ error: 'folder_not_empty' }) + } + + await getPool().query('DELETE FROM folders WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId]) + res.status(204).end() +}) + +app.patch('/api/templates/:id/folder', async (req, res) => { + const folderId = req.body?.folderId ? String(req.body.folderId) : null + + if (folderId) { + const folder = await getPool().query( + 'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2', + [folderId, tenantId] + ) + if (!folder.rowCount) return res.status(400).json({ error: 'invalid_folder' }) + } + + const { rows } = await getPool().query( + `UPDATE templates + SET folder_id=$1,updated_by=$2,updated_at=now() + WHERE id=$3 AND tenant_id=$4 + RETURNING id,template_key,name,type,status,published_version,folder_id,updated_at`, + [folderId, userId(req), req.params.id, tenantId] + ) + if (!rows[0]) return res.status(404).json({ error: 'not_found' }) + res.json(rows[0]) +}) + app.get('/api/templates', async (_req, res) => { const { rows } = await getPool().query( - `SELECT id,template_key,name,type,status,published_version,updated_at + `SELECT id,template_key,name,type,status,published_version,folder_id,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC`, @@ -370,8 +516,17 @@ app.get('/api/templates', async (_req, res) => { app.post('/api/templates', async (req, res) => { const name = String(req.body?.name || '').trim() const type = req.body?.type === 'email' ? 'email' : 'report' + const folderId = req.body?.folderId ? String(req.body.folderId) : null if (!name) return res.status(400).json({ error: 'name_required' }) + if (folderId) { + const folder = await getPool().query( + 'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2', + [folderId, tenantId] + ) + if (!folder.rowCount) return res.status(400).json({ error: 'invalid_folder' }) + } + const id = crypto.randomUUID() const templateKey = await newTemplateKey(name) const document = defaultDocument(type) @@ -383,10 +538,10 @@ app.post('/api/templates', async (req, res) => { } const { rows } = await getPool().query( `INSERT INTO templates - (id,tenant_id,template_key,name,type,draft_document,sample_data,created_by,updated_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$8) + (id,tenant_id,template_key,name,type,folder_id,draft_document,sample_data,created_by,updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9) RETURNING *`, - [id, tenantId, templateKey, name, type, document, sampleData, userId(req)] + [id, tenantId, templateKey, name, type, folderId, document, sampleData, userId(req)] ) res.status(201).json(rows[0]) })