feat: add nested template folders backend
This commit is contained in:
parent
30141528d2
commit
385f5a819a
25
server/db.js
25
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(`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(`
|
await db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS template_versions (
|
CREATE TABLE IF NOT EXISTS template_versions (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
@ -118,6 +131,18 @@ export async function initDb() {
|
|||||||
CREATE INDEX IF NOT EXISTS templates_tenant_updated_idx
|
CREATE INDEX IF NOT EXISTS templates_tenant_updated_idx
|
||||||
ON templates (tenant_id, updated_at DESC)
|
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(`
|
await db.query(`
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS templates_tenant_key_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS templates_tenant_key_idx
|
||||||
ON templates (tenant_id, template_key)
|
ON templates (tenant_id, template_key)
|
||||||
|
|||||||
163
server/index.js
163
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) => {
|
app.get('/api/templates', async (_req, res) => {
|
||||||
const { rows } = await getPool().query(
|
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
|
FROM templates
|
||||||
WHERE tenant_id=$1
|
WHERE tenant_id=$1
|
||||||
ORDER BY updated_at DESC`,
|
ORDER BY updated_at DESC`,
|
||||||
@ -370,8 +516,17 @@ app.get('/api/templates', async (_req, res) => {
|
|||||||
app.post('/api/templates', async (req, res) => {
|
app.post('/api/templates', async (req, res) => {
|
||||||
const name = String(req.body?.name || '').trim()
|
const name = String(req.body?.name || '').trim()
|
||||||
const type = req.body?.type === 'email' ? 'email' : 'report'
|
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 (!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 id = crypto.randomUUID()
|
||||||
const templateKey = await newTemplateKey(name)
|
const templateKey = await newTemplateKey(name)
|
||||||
const document = defaultDocument(type)
|
const document = defaultDocument(type)
|
||||||
@ -383,10 +538,10 @@ app.post('/api/templates', async (req, res) => {
|
|||||||
}
|
}
|
||||||
const { rows } = await getPool().query(
|
const { rows } = await getPool().query(
|
||||||
`INSERT INTO templates
|
`INSERT INTO templates
|
||||||
(id,tenant_id,template_key,name,type,draft_document,sample_data,created_by,updated_by)
|
(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,$8)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9)
|
||||||
RETURNING *`,
|
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])
|
res.status(201).json(rows[0])
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user