From 6d50f9d42f4e19028a377c6065687b6d87e14a09 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 02:36:52 +0000 Subject: [PATCH] feat: add template studio service foundation --- .env.example | 7 ++ .gitignore | 7 ++ Dockerfile | 22 +++++ package.json | 35 ++++++++ server/auth.js | 45 +++++++++++ server/db.js | 52 ++++++++++++ server/index.js | 147 ++++++++++++++++++++++++++++++++++ server/templateEngine.js | 93 +++++++++++++++++++++ server/templateEngine.test.js | 19 +++++ tsconfig.json | 20 +++++ vite.config.ts | 29 +++++++ 11 files changed, 476 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 package.json create mode 100644 server/auth.js create mode 100644 server/db.js create mode 100644 server/index.js create mode 100644 server/templateEngine.js create mode 100644 server/templateEngine.test.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dd9f697 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PORT=3000 +DATABASE_URL=postgresql://template_studio:change-me@localhost:5432/template_studio +TEMPLATE_TENANT_ID=default +AUTH_REQUIRED=false +KEYCLOAK_ISSUER=https://auth.example.com/realms/dev +KEYCLOAK_CLIENT_ID=template-studio +CHROMIUM_PATH=/usr/bin/chromium diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4996c7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.* +!.env.example +.DS_Store +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..888076b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run typecheck && npm run test && npm run build + +FROM node:22-bookworm-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN apt-get update \ + && apt-get install -y --no-install-recommends chromium ca-certificates fonts-liberation \ + && rm -rf /var/lib/apt/lists/* +COPY package.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +COPY server ./server +ENV PORT=3000 +ENV CHROMIUM_PATH=/usr/bin/chromium +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/api/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))" +CMD ["node", "server/index.js"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..c94b563 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "template-studio", + "version": "0.1.0", + "private": false, + "type": "module", + "description": "Mobile-first email template and report builder for Arrowstack.", + "scripts": { + "dev": "concurrently \"node --watch server/index.js\" \"vite --host 0.0.0.0\"", + "build": "vite build", + "start": "node server/index.js", + "test": "node --test server/*.test.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "express": "^5.1.0", + "idb-keyval": "^6.2.1", + "jose": "^6.1.0", + "keycloak-js": "^26.2.0", + "mjml": "^4.15.3", + "pg": "^8.16.3", + "playwright-core": "^1.55.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "concurrently": "^9.2.1", + "typescript": "^5.9.2", + "vite": "^7.1.7", + "vite-plugin-pwa": "^1.0.3" + } +} diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..528a052 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,45 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose' + +let jwks + +function authConfig() { + const issuer = process.env.KEYCLOAK_ISSUER?.replace(/\/$/, '') + const clientId = process.env.KEYCLOAK_CLIENT_ID + return { issuer, clientId } +} + +export function publicAuthConfig() { + const { issuer, clientId } = authConfig() + return { + authRequired: process.env.AUTH_REQUIRED !== 'false', + issuer: issuer || '', + clientId: clientId || '' + } +} + +export async function requireAuth(req, res, next) { + if (process.env.AUTH_REQUIRED === 'false') { + req.user = { sub: 'local-dev', preferred_username: 'local-dev' } + return next() + } + + try { + const { issuer, clientId } = authConfig() + if (!issuer || !clientId) throw new Error('Keycloak is not configured') + const header = req.headers.authorization || '' + if (!header.startsWith('Bearer ')) return res.status(401).json({ error: 'missing_bearer_token' }) + + jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`)) + const { payload } = await jwtVerify(header.slice(7), jwks, { issuer }) + + const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean) + if (payload.azp !== clientId && !aud.includes(clientId)) { + return res.status(403).json({ error: 'wrong_client' }) + } + + req.user = payload + next() + } catch (error) { + res.status(401).json({ error: 'invalid_token', detail: error.message }) + } +} diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..a835e34 --- /dev/null +++ b/server/db.js @@ -0,0 +1,52 @@ +import pg from 'pg' + +const { Pool } = pg +let pool + +export function getPool() { + if (!pool) { + if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required') + pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 10, + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : undefined + }) + } + return pool +} + +export async function initDb() { + const db = getPool() + await db.query(` + CREATE TABLE IF NOT EXISTS templates ( + id text PRIMARY KEY, + tenant_id text NOT NULL, + name text NOT NULL, + type text NOT NULL CHECK (type IN ('email', 'report')), + status text NOT NULL DEFAULT 'draft', + draft_document jsonb NOT NULL, + sample_data jsonb NOT NULL DEFAULT '{}'::jsonb, + published_version integer, + created_by text, + updated_by text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `) + await db.query(` + CREATE TABLE IF NOT EXISTS template_versions ( + id text PRIMARY KEY, + template_id text NOT NULL REFERENCES templates(id) ON DELETE CASCADE, + version integer NOT NULL, + document jsonb NOT NULL, + sample_data jsonb NOT NULL DEFAULT '{}'::jsonb, + created_by text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(template_id, version) + ) + `) + await db.query(` + CREATE INDEX IF NOT EXISTS templates_tenant_updated_idx + ON templates (tenant_id, updated_at DESC) + `) +} diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..64959a5 --- /dev/null +++ b/server/index.js @@ -0,0 +1,147 @@ +import crypto from 'node:crypto' +import express from 'express' +import { chromium } from 'playwright-core' +import { initDb, getPool } from './db.js' +import { publicAuthConfig, requireAuth } from './auth.js' +import { renderEmailHtml, renderReportHtml } from './templateEngine.js' + +const app = express() +const port = Number(process.env.PORT || 3000) +const tenantId = process.env.TEMPLATE_TENANT_ID || 'default' + +app.use(express.json({ limit: '2mb' })) +app.get('/api/health/ready', async (_req, res) => { + try { + await getPool().query('SELECT 1') + res.json({ ok: true }) + } catch (error) { + res.status(503).json({ ok: false, error: error.message }) + } +}) +app.get('/api/config', (_req, res) => res.json(publicAuthConfig())) +app.use('/api', requireAuth) + +function userId(req) { + return req.user?.preferred_username || req.user?.sub || 'unknown' +} + +function defaultDocument(type) { + return { + blocks: [ + { id: crypto.randomUUID(), type: 'heading', props: { text: type === 'email' ? 'Hello {{customer.name}}' : 'Report — {{report.date}}' } }, + { id: crypto.randomUUID(), type: 'text', props: { text: type === 'email' ? 'Add your message here.' : 'Add report context here.' } }, + { id: crypto.randomUUID(), type: 'kpi', props: { label: 'Total', value: '{{summary.total}}' } } + ] + } +} + +app.get('/api/templates', async (req, res) => { + const { rows } = await getPool().query( + 'SELECT id,name,type,status,published_version,updated_at FROM templates WHERE tenant_id=$1 ORDER BY updated_at DESC', + [tenantId] + ) + res.json(rows) +}) + +app.post('/api/templates', async (req, res) => { + const name = String(req.body?.name || '').trim() + const type = req.body?.type === 'email' ? 'email' : 'report' + if (!name) return res.status(400).json({ error: 'name_required' }) + const id = crypto.randomUUID() + const document = defaultDocument(type) + const sampleData = { customer: { name: 'Example Customer' }, report: { date: new Date().toISOString().slice(0, 10) }, summary: { total: 125 } } + const { rows } = await getPool().query( + `INSERT INTO templates (id,tenant_id,name,type,draft_document,sample_data,created_by,updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$7) RETURNING *`, + [id, tenantId, name, type, document, sampleData, userId(req)] + ) + res.status(201).json(rows[0]) +}) + +app.get('/api/templates/:id', async (req, res) => { + const { rows } = await getPool().query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId]) + if (!rows[0]) return res.status(404).json({ error: 'not_found' }) + res.json(rows[0]) +}) + +app.put('/api/templates/:id/draft', async (req, res) => { + const document = req.body?.document + const sampleData = req.body?.sampleData ?? {} + if (!document || !Array.isArray(document.blocks)) return res.status(400).json({ error: 'invalid_document' }) + const { rows } = await getPool().query( + `UPDATE templates SET draft_document=$1,sample_data=$2,updated_by=$3,updated_at=now() + WHERE id=$4 AND tenant_id=$5 RETURNING *`, + [document, sampleData, userId(req), req.params.id, tenantId] + ) + if (!rows[0]) return res.status(404).json({ error: 'not_found' }) + res.json(rows[0]) +}) + +app.post('/api/templates/:id/publish', async (req, res) => { + const db = await getPool().connect() + try { + await db.query('BEGIN') + const current = await db.query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 FOR UPDATE', [req.params.id, tenantId]) + const template = current.rows[0] + if (!template) { + await db.query('ROLLBACK') + return res.status(404).json({ error: 'not_found' }) + } + const nextVersion = (template.published_version || 0) + 1 + await db.query( + `INSERT INTO template_versions (id,template_id,version,document,sample_data,created_by) + VALUES ($1,$2,$3,$4,$5,$6)`, + [crypto.randomUUID(), template.id, nextVersion, template.draft_document, template.sample_data, userId(req)] + ) + const updated = await db.query( + `UPDATE templates SET status='published',published_version=$1,updated_at=now(),updated_by=$2 + WHERE id=$3 RETURNING *`, + [nextVersion, userId(req), template.id] + ) + await db.query('COMMIT') + res.json(updated.rows[0]) + } catch (error) { + await db.query('ROLLBACK') + throw error + } finally { + db.release() + } +}) + +app.post('/api/templates/:id/render', async (req, res) => { + const { rows } = await getPool().query('SELECT * FROM templates WHERE id=$1 AND tenant_id=$2', [req.params.id, tenantId]) + const template = rows[0] + if (!template) return res.status(404).json({ error: 'not_found' }) + + const data = req.body?.data ?? template.sample_data ?? {} + const format = req.body?.format || (template.type === 'email' ? 'email' : 'html') + const document = template.draft_document + + if (format === 'email') return res.json({ html: renderEmailHtml(document, data) }) + const html = renderReportHtml(document, data) + if (format === 'html') return res.json({ html }) + if (format !== 'pdf') return res.status(400).json({ error: 'unsupported_format' }) + + const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium', + args: ['--no-sandbox', '--disable-dev-shm-usage'] + }) + try { + const page = await browser.newPage() + await page.setContent(html, { waitUntil: 'networkidle' }) + const pdf = await page.pdf({ format: 'A4', printBackground: true }) + res.type('application/pdf').send(pdf) + } finally { + await browser.close() + } +}) + +app.use(express.static('dist')) +app.get(/^(?!\/api).*/, (_req, res) => res.sendFile('index.html', { root: 'dist' })) +app.use((error, _req, res, _next) => { + console.error(error) + res.status(500).json({ error: 'internal_error' }) +}) + +await initDb() +app.listen(port, '0.0.0.0', () => console.log(`Template Studio listening on :${port}`)) diff --git a/server/templateEngine.js b/server/templateEngine.js new file mode 100644 index 0000000..7f7d7da --- /dev/null +++ b/server/templateEngine.js @@ -0,0 +1,93 @@ +import mjml2html from 'mjml' + +function getPath(data, path) { + return String(path || '').split('.').filter(Boolean).reduce((value, key) => value?.[key], data) +} + +export function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function interpolate(input, data) { + return String(input ?? '').replace(/{{\s*([\w.[\]-]+)\s*}}/g, (_, path) => { + const normalized = path.replace(/\[(\d+)\]/g, '.$1') + const value = getPath(data, normalized) + return value == null ? '' : String(value) + }) +} + +function safeUrl(input, data) { + const url = interpolate(input, data).trim() + return /^(https?:|mailto:)/i.test(url) ? url : '#' +} + +function renderTable(block, data) { + const rows = getPath(data, block.props?.rowsPath) || [] + const columns = Array.isArray(block.props?.columns) ? block.props.columns : [] + if (!Array.isArray(rows)) return '' + return `
${columns.map(c => ``).join('')}${rows.map(row => `${columns.map(c => ``).join('')}`).join('')}
${escapeHtml(c.label)}
${escapeHtml(getPath(row, c.key))}
` +} + +function blockHtml(block, data) { + const p = block.props || {} + switch (block.type) { + case 'heading': + return `

${escapeHtml(interpolate(p.text, data))}

` + case 'text': + return `

${escapeHtml(interpolate(p.text, data))}

` + case 'kpi': + return `
${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}
` + case 'button': + return `

${escapeHtml(interpolate(p.text, data))}

` + case 'image': + return `${escapeHtml(interpolate(p.alt, data))}` + case 'divider': + return '
' + case 'table': + return renderTable(block, data) + default: + return '' + } +} + +export function renderReportHtml(template, data = {}) { + const blocks = template?.blocks || [] + return ` + +${blocks.map(block => blockHtml(block, data)).join('\n')}` +} + +function emailBlock(block, data) { + const p = block.props || {} + switch (block.type) { + case 'heading': + return `${escapeHtml(interpolate(p.text, data))}` + case 'text': + return `${escapeHtml(interpolate(p.text, data))}` + case 'kpi': + return `${escapeHtml(interpolate(p.label, data))}${escapeHtml(interpolate(p.value, data))}` + case 'button': + return `${escapeHtml(interpolate(p.text, data))}` + case 'image': + return `` + case 'divider': + return '' + default: + return 'This block is available in report output only.' + } +} + +export function renderEmailHtml(template, data = {}) { + const body = (template?.blocks || []).map(block => emailBlock(block, data)).join('\n') + const mjml = `${body}` + const result = mjml2html(mjml, { validationLevel: 'soft' }) + if (result.errors?.length) console.warn('MJML warnings', result.errors) + return result.html +} diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js new file mode 100644 index 0000000..ff6974a --- /dev/null +++ b/server/templateEngine.test.js @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { interpolate, renderReportHtml, renderEmailHtml } from './templateEngine.js' + +test('interpolates nested values', () => { + assert.equal(interpolate('Hello {{customer.name}}', { customer: { name: 'Kauri' } }), 'Hello Kauri') +}) + +test('report output escapes injected markup', () => { + const html = renderReportHtml({ blocks: [{ type: 'text', props: { text: '{{value}}' } }] }, { value: '' }) + assert.match(html, /<script>/) + assert.doesNotMatch(html, /