feat: wire template pack import and styled rendering
This commit is contained in:
parent
876e7d57af
commit
0bbac8da30
@ -4,6 +4,7 @@ import { chromium } from 'playwright-core'
|
||||
import { initDb, getPool } from './db.js'
|
||||
import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js'
|
||||
import { renderEmailHtml, renderReportHtml } from './templateEngine.js'
|
||||
import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT || 3000)
|
||||
@ -24,6 +25,20 @@ app.get('/api/health/ready', async (_req, res) => {
|
||||
|
||||
registerAuthRoutes(app)
|
||||
|
||||
app.get('/media/:token/:filename', async (req, res, next) => {
|
||||
try {
|
||||
const asset = await getTemplateAsset(req.params.token)
|
||||
if (!asset) return res.status(404).end()
|
||||
res.setHeader('Content-Type', asset.mime_type)
|
||||
res.setHeader('Content-Length', String(asset.byte_size))
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.send(asset.data)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
function userId(req) {
|
||||
return req.user?.preferred_username || req.user?.sub || 'unknown'
|
||||
}
|
||||
@ -235,6 +250,38 @@ app.post('/api/v1/templates/:key/render', async (req, res) => {
|
||||
*/
|
||||
app.use('/api', requireAuth)
|
||||
|
||||
app.get('/api/template-packs/spec', (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json(templatePackSpec)
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/api/template-packs/import',
|
||||
express.raw({
|
||||
type: ['application/zip', 'application/x-zip-compressed', 'application/octet-stream'],
|
||||
limit: '20mb'
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '')
|
||||
const imported = await importTemplatePack(req.body, {
|
||||
tenantId,
|
||||
createdBy: userId(req),
|
||||
baseUrl
|
||||
})
|
||||
res.status(201).json(imported)
|
||||
} catch (error) {
|
||||
if (error?.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: 'pack_too_large' })
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return res.status(400).json({ error: 'invalid_template_pack', detail: error.message })
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
app.get('/api/templates', async (_req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT id,template_key,name,type,status,published_version,updated_at
|
||||
|
||||
@ -26,6 +26,64 @@ function safeUrl(input, data) {
|
||||
return /^(https?:|mailto:)/i.test(url) ? url : '#'
|
||||
}
|
||||
|
||||
function safeHex(value, fallback) {
|
||||
const color = String(value || '').trim()
|
||||
return /^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(color) ? color : fallback
|
||||
}
|
||||
|
||||
function safeNumber(value, fallback, min, max) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number)) return fallback
|
||||
return Math.min(max, Math.max(min, number))
|
||||
}
|
||||
|
||||
function safeAlign(value) {
|
||||
return ['left', 'center', 'right'].includes(value) ? value : 'left'
|
||||
}
|
||||
|
||||
function safeFont(value) {
|
||||
const font = String(value || '')
|
||||
const allowed = ['Arial', 'Helvetica', 'Verdana', 'Georgia', 'Times New Roman', 'system-ui']
|
||||
return allowed.includes(font) ? font : 'Arial'
|
||||
}
|
||||
|
||||
function themeOf(template) {
|
||||
const theme = template?.theme || {}
|
||||
return {
|
||||
backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'),
|
||||
surfaceColor: safeHex(theme.surfaceColor, '#ffffff'),
|
||||
textColor: safeHex(theme.textColor, '#172033'),
|
||||
mutedColor: safeHex(theme.mutedColor, '#64748b'),
|
||||
accentColor: safeHex(theme.accentColor, '#111827'),
|
||||
buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'),
|
||||
borderColor: safeHex(theme.borderColor, '#dbe2ea'),
|
||||
borderRadius: safeNumber(theme.borderRadius, 12, 0, 32),
|
||||
pagePadding: safeNumber(theme.pagePadding, 18, 0, 40),
|
||||
fontFamily: safeFont(theme.fontFamily),
|
||||
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
|
||||
borderImage: safeUrl(theme.borderImage, {})
|
||||
}
|
||||
}
|
||||
|
||||
function reportBlockStyle(block, theme) {
|
||||
const style = block?.style || {}
|
||||
const rules = [
|
||||
`background:${safeHex(style.backgroundColor, 'transparent')}`,
|
||||
`color:${safeHex(style.textColor, theme.textColor)}`,
|
||||
`border-color:${safeHex(style.borderColor, theme.borderColor)}`,
|
||||
`border-width:${safeNumber(style.borderWidth, 0, 0, 8)}px`,
|
||||
'border-style:solid',
|
||||
`border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`,
|
||||
`padding:${safeNumber(style.padding, 0, 0, 48)}px`,
|
||||
`text-align:${safeAlign(style.align)}`
|
||||
]
|
||||
return rules.join(';')
|
||||
}
|
||||
|
||||
function wrapReportBlock(block, theme, html) {
|
||||
return `<div class="block" style="${reportBlockStyle(block, theme)}">${html}</div>`
|
||||
}
|
||||
|
||||
function renderTable(block, data) {
|
||||
const rows = getPath(data, block.props?.rowsPath) || []
|
||||
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
|
||||
@ -33,60 +91,85 @@ function renderTable(block, data) {
|
||||
return `<div class="table-wrap"><table><thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escapeHtml(getPath(row, c.key))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
|
||||
}
|
||||
|
||||
function blockHtml(block, data) {
|
||||
function blockHtml(block, data, theme) {
|
||||
const p = block.props || {}
|
||||
let html = ''
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return `<h2>${escapeHtml(interpolate(p.text, data))}</h2>`
|
||||
html = `<h2>${escapeHtml(interpolate(p.text, data))}</h2>`
|
||||
break
|
||||
case 'text':
|
||||
return `<p>${escapeHtml(interpolate(p.text, data))}</p>`
|
||||
html = `<p>${escapeHtml(interpolate(p.text, data))}</p>`
|
||||
break
|
||||
case 'kpi':
|
||||
return `<div class="kpi"><span>${escapeHtml(interpolate(p.label, data))}</span><strong>${escapeHtml(interpolate(p.value, data))}</strong></div>`
|
||||
html = `<div class="kpi"><span>${escapeHtml(interpolate(p.label, data))}</span><strong>${escapeHtml(interpolate(p.value, data))}</strong></div>`
|
||||
break
|
||||
case 'button':
|
||||
return `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
||||
html = `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
||||
break
|
||||
case 'image':
|
||||
return `<img class="image" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
html = `<img class="image" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
break
|
||||
case 'divider':
|
||||
return '<hr />'
|
||||
html = '<hr />'
|
||||
break
|
||||
case 'table':
|
||||
return renderTable(block, data)
|
||||
html = renderTable(block, data)
|
||||
break
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
return wrapReportBlock(block, theme, html)
|
||||
}
|
||||
|
||||
export function renderReportHtml(template, data = {}) {
|
||||
const blocks = template?.blocks || []
|
||||
const theme = themeOf(template)
|
||||
const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")`
|
||||
const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch`
|
||||
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
@page{size:A4;margin:18mm}*{box-sizing:border-box}body{font:15px/1.5 system-ui,-apple-system,sans-serif;color:#172033;margin:0}h2{font-size:24px;margin:0 0 16px}p{margin:0 0 14px}.kpi{border:1px solid #dbe2ea;border-radius:12px;padding:14px;margin:0 0 12px;display:flex;justify-content:space-between;gap:16px}.kpi span{color:#64748b}.kpi strong{font-size:18px}.button{display:inline-block;background:#111827;color:white;text-decoration:none;padding:10px 16px;border-radius:10px}.image{max-width:100%;height:auto;border-radius:10px}hr{border:0;border-top:1px solid #dbe2ea;margin:18px 0}.table-wrap{overflow:hidden;border:1px solid #dbe2ea;border-radius:10px;margin:0 0 16px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid #eef2f7}th{background:#f8fafc}
|
||||
</style></head><body>${blocks.map(block => blockHtml(block, data)).join('\n')}</body></html>`
|
||||
@page{size:A4;margin:14mm}*{box-sizing:border-box}body{font:${15}px/1.5 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:no-repeat}.document{min-height:100%;background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 12}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 12px}.block:last-child{margin-bottom:0}h2{font-size:24px;margin:0 0 16px;color:inherit}p{margin:0 0 14px}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{max-width:100%;height:auto;border-radius:${theme.borderRadius}px}hr{border:0;border-top:1px solid ${theme.borderColor};margin:18px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}}
|
||||
</style></head><body><main class="document">${blocks.map(block => blockHtml(block, data, theme)).join('\n')}</main></body></html>`
|
||||
}
|
||||
|
||||
function emailBlock(block, data) {
|
||||
function emailBlock(block, data, theme) {
|
||||
const p = block.props || {}
|
||||
const style = block.style || {}
|
||||
const padding = `${safeNumber(style.padding, 0, 0, 48)}px`
|
||||
const align = safeAlign(style.align)
|
||||
const color = safeHex(style.textColor, theme.textColor)
|
||||
const backgroundColor = safeHex(style.backgroundColor, theme.surfaceColor)
|
||||
const borderColor = safeHex(style.borderColor, theme.borderColor)
|
||||
const borderWidth = safeNumber(style.borderWidth, 0, 0, 8)
|
||||
const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32)
|
||||
const common = `padding="${padding}" align="${align}" color="${color}"`
|
||||
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return `<mj-text font-size="24px" font-weight="700">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
return `<mj-text ${common} font-size="24px" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
case 'text':
|
||||
return `<mj-text>${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
return `<mj-text ${common} container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.text, data))}</mj-text>`
|
||||
case 'kpi':
|
||||
return `<mj-text color="#64748b">${escapeHtml(interpolate(p.label, data))}</mj-text><mj-text font-size="22px" font-weight="700">${escapeHtml(interpolate(p.value, data))}</mj-text>`
|
||||
return `<mj-text padding="${padding}" color="${theme.mutedColor}" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.label, data))}</mj-text><mj-text padding="0 ${padding} ${padding}" color="${color}" font-size="22px" font-weight="700" container-background-color="${backgroundColor}">${escapeHtml(interpolate(p.value, data))}</mj-text>`
|
||||
case 'button':
|
||||
return `<mj-button background-color="#111827" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</mj-button>`
|
||||
return `<mj-button background-color="${safeHex(style.backgroundColor, theme.accentColor)}" color="${safeHex(style.textColor, theme.buttonTextColor)}" border-radius="${radius}px" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</mj-button>`
|
||||
case 'image':
|
||||
return `<mj-image src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
return `<mj-image padding="${padding}" border="${borderWidth}px solid ${borderColor}" border-radius="${radius}px" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
case 'divider':
|
||||
return '<mj-divider border-color="#dbe2ea" />'
|
||||
return `<mj-divider border-color="${borderColor}" border-width="${Math.max(1, borderWidth)}px" />`
|
||||
default:
|
||||
return '<mj-text color="#64748b">This block is available in report output only.</mj-text>'
|
||||
}
|
||||
}
|
||||
|
||||
export function renderEmailHtml(template, data = {}) {
|
||||
const body = (template?.blocks || []).map(block => emailBlock(block, data)).join('\n')
|
||||
const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="Arial, sans-serif" /><mj-text font-size="15px" line-height="1.5" color="#172033" /></mj-attributes></mj-head><mj-body background-color="#f1f5f9"><mj-section background-color="#ffffff" padding="24px"><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
||||
const theme = themeOf(template)
|
||||
const body = (template?.blocks || []).map(block => emailBlock(block, data, theme)).join('\n')
|
||||
const backgroundUrl = theme.pageBackgroundImage === '#' ? '' : ` background-url="${escapeHtml(theme.pageBackgroundImage)}" background-size="cover" background-repeat="no-repeat"`
|
||||
const mjml = `<mjml><mj-head><mj-attributes><mj-all font-family="${theme.fontFamily}, sans-serif" /><mj-text font-size="15px" line-height="1.5" color="${theme.textColor}" /></mj-attributes></mj-head><mj-body background-color="${theme.backgroundColor}"><mj-section background-color="${theme.surfaceColor}" padding="${theme.pagePadding}px"${backgroundUrl}><mj-column>${body}</mj-column></mj-section></mj-body></mjml>`
|
||||
const result = mjml2html(mjml, { validationLevel: 'soft' })
|
||||
if (result.errors?.length) console.warn('MJML warnings', result.errors)
|
||||
return result.html
|
||||
|
||||
25
src/api.ts
25
src/api.ts
@ -1,6 +1,8 @@
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
||||
if (init.body && !(init.body instanceof FormData) && !(init.body instanceof Blob) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const response = await fetch(path, { ...init, headers })
|
||||
if (response.status === 401) {
|
||||
@ -16,7 +18,9 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
|
||||
export async function apiBlob(path: string, init: RequestInit = {}): Promise<Blob> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
||||
if (init.body && !(init.body instanceof FormData) && !(init.body instanceof Blob) && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const response = await fetch(path, { ...init, headers })
|
||||
if (response.status === 401) {
|
||||
@ -26,3 +30,20 @@ export async function apiBlob(path: string, init: RequestInit = {}): Promise<Blo
|
||||
if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`)
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
export async function apiZip<T>(path: string, file: File): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
body: file
|
||||
})
|
||||
if (response.status === 401) {
|
||||
window.location.assign('/auth/login')
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(`${response.status}: ${body}`)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
@ -5,9 +5,11 @@ export type Block = {
|
||||
id: string
|
||||
type: BlockType
|
||||
props: Record<string, unknown>
|
||||
style?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type TemplateDocument = {
|
||||
theme?: Record<string, unknown>
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user