feat: add nested template folders backend

This commit is contained in:
Admin 2026-09-23 07:49:48 +00:00
parent 30141528d2
commit 385f5a819a
2 changed files with 184 additions and 4 deletions

View File

@ -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)

View File

@ -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])
})