Migrate tasks schema to v2 with priority

This commit is contained in:
Admin 2026-09-17 08:42:10 +00:00
parent 51435b9307
commit 35626f8087

View File

@ -15,6 +15,7 @@ for (const [name, value] of Object.entries({ APP_BASE_URL, ISSUER, CLIENT_ID, CL
const pool = new Pool({ connectionString: DATABASE_URL, max: 5 }); const pool = new Pool({ connectionString: DATABASE_URL, max: 5 });
const sessions = new Map(); const sessions = new Map();
const PRIORITIES = new Set(['low', 'normal', 'high']);
function esc(value) { function esc(value) {
return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]); return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[ch]);
@ -66,14 +67,23 @@ function sessionFor(req) {
return session; return session;
} }
function normalisePriority(value) {
const priority = String(value || 'normal').toLowerCase();
return PRIORITIES.has(priority) ? priority : 'normal';
}
function priorityOptions(current = 'normal') {
return ['low', 'normal', 'high'].map(value => `<option value="${value}"${value === current ? ' selected' : ''}>${value[0].toUpperCase()}${value.slice(1)}</option>`).join('');
}
function layout(user, content) { function layout(user, content) {
return `<!doctype html> return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Arrowstack Tasks</title> <title>Arrowstack Tasks</title>
<style> <style>
:root{font-family:Inter,system-ui,sans-serif;color:#e7edf7;background:#0b1020}*{box-sizing:border-box}body{margin:0;background:linear-gradient(160deg,#0b1020,#111a31 55%,#0b1020);min-height:100vh}.wrap{max-width:920px;margin:auto;padding:36px 20px}.top{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:28px}.brand h1{margin:0;font-size:28px}.muted{color:#9ba9bf}.card{background:#121a2d;border:1px solid #27334d;border-radius:16px;padding:18px;box-shadow:0 18px 50px #0005}.add{display:grid;grid-template-columns:1fr auto;gap:10px;margin-bottom:18px}input,button{font:inherit;border-radius:10px;border:1px solid #34415e;padding:11px 13px}input{background:#0d1527;color:#eef4ff;width:100%}button{background:#4f7cff;color:white;border:0;font-weight:650;cursor:pointer}button.secondary{background:#25314a}button.danger{background:#7e3040}.task{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:14px 0;border-top:1px solid #26334c}.task:first-of-type{border-top:0}.done .title{text-decoration:line-through;color:#8190a7}.actions{display:flex;gap:8px;flex-wrap:wrap}.actions form{margin:0}.inline-edit{display:flex;gap:8px}.pill{display:inline-block;background:#202d47;border:1px solid #34415e;border-radius:999px;padding:6px 10px;color:#b9c7db;font-size:13px}.empty{text-align:center;padding:42px 10px;color:#91a0b8}a{color:#9fc0ff;text-decoration:none}@media(max-width:700px){.task{grid-template-columns:auto 1fr}.actions{grid-column:1/-1}.top{align-items:flex-start;flex-direction:column}.add{grid-template-columns:1fr}} :root{font-family:Inter,system-ui,sans-serif;color:#e7edf7;background:#0b1020}*{box-sizing:border-box}body{margin:0;background:linear-gradient(160deg,#0b1020,#111a31 55%,#0b1020);min-height:100vh}.wrap{max-width:920px;margin:auto;padding:36px 20px}.top{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:28px}.brand h1{margin:0;font-size:28px}.muted{color:#9ba9bf}.card{background:#121a2d;border:1px solid #27334d;border-radius:16px;padding:18px;box-shadow:0 18px 50px #0005}.add{display:grid;grid-template-columns:1fr 130px auto;gap:10px;margin-bottom:18px}input,select,button{font:inherit;border-radius:10px;border:1px solid #34415e;padding:11px 13px}input,select{background:#0d1527;color:#eef4ff;width:100%}button{background:#4f7cff;color:white;border:0;font-weight:650;cursor:pointer}button.secondary{background:#25314a}button.danger{background:#7e3040}.task{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:14px 0;border-top:1px solid #26334c}.task:first-of-type{border-top:0}.done .title{text-decoration:line-through;color:#8190a7}.actions{display:flex;gap:8px;flex-wrap:wrap}.actions form{margin:0}.inline-edit{display:grid;grid-template-columns:1fr 120px auto;gap:8px}.pill{display:inline-block;background:#202d47;border:1px solid #34415e;border-radius:999px;padding:6px 10px;color:#b9c7db;font-size:13px}.schema{display:inline-block;margin-left:8px;background:#173d2e;border:1px solid #2f6a51;border-radius:999px;padding:4px 8px;color:#9fe0bb;font-size:12px}.priority{display:inline-block;margin-top:7px;border-radius:999px;padding:3px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.04em}.priority-low{background:#1d3448;color:#9dccf1}.priority-normal{background:#30334b;color:#c7c9e9}.priority-high{background:#4a2833;color:#ffb2c1}.empty{text-align:center;padding:42px 10px;color:#91a0b8}a{color:#9fc0ff;text-decoration:none}@media(max-width:700px){.task{grid-template-columns:auto 1fr}.actions{grid-column:1/-1}.top{align-items:flex-start;flex-direction:column}.add,.inline-edit{grid-template-columns:1fr}}
</style></head><body><div class="wrap"> </style></head><body><div class="wrap">
<div class="top"><div class="brand"><h1>Arrowstack Tasks</h1><div class="muted">Keycloak + dedicated PostgreSQL CRUD demo</div></div><div><span class="pill">${esc(user.preferred_username || user.email || 'user')}</span> &nbsp; <a href="/logout">Logout</a></div></div> <div class="top"><div class="brand"><h1>Arrowstack Tasks <span class="schema">schema v2</span></h1><div class="muted">Keycloak + dedicated PostgreSQL CRUD demo</div></div><div><span class="pill">${esc(user.preferred_username || user.email || 'user')}</span> &nbsp; <a href="/logout">Logout</a></div></div>
${content} ${content}
</div></body></html>`; </div></body></html>`;
} }
@ -84,24 +94,33 @@ async function initDb() {
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200), title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
completed BOOLEAN NOT NULL DEFAULT FALSE, completed BOOLEAN NOT NULL DEFAULT FALSE,
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high')),
created_by TEXT NOT NULL, created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) )
`); `);
await pool.query(`
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high'))
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_created_at ON tasks (created_by, created_at DESC)`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_created_at ON tasks (created_by, created_at DESC)`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_tasks_created_by_priority_created_at ON tasks (created_by, priority, created_at DESC)`);
} }
await initDb(); await initDb();
console.log('database schema ready'); console.log('database schema v2 ready');
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
try { try {
const url = new URL(req.url, APP_BASE_URL); const url = new URL(req.url, APP_BASE_URL);
if (url.pathname === '/health') { if (url.pathname === '/health') {
await pool.query('SELECT 1'); await pool.query('SELECT priority FROM tasks LIMIT 1');
return send(res, 200, JSON.stringify({ ok: true, database: 'connected' }), 'application/json; charset=utf-8'); return send(res, 200, JSON.stringify({ ok: true, database: 'connected', schema: 2 }), 'application/json; charset=utf-8');
} }
if (url.pathname === '/login') { if (url.pathname === '/login') {
@ -161,7 +180,8 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && url.pathname === '/tasks') { if (req.method === 'POST' && url.pathname === '/tasks') {
const form = await readForm(req); const form = await readForm(req);
const title = String(form.title || '').trim().slice(0, 200); const title = String(form.title || '').trim().slice(0, 200);
if (title) await pool.query('INSERT INTO tasks (title, created_by) VALUES ($1, $2)', [title, userKey]); const priority = normalisePriority(form.priority);
if (title) await pool.query('INSERT INTO tasks (title, priority, created_by) VALUES ($1, $2, $3)', [title, priority, userKey]);
return redirect(res, '/'); return redirect(res, '/');
} }
@ -174,7 +194,8 @@ const server = http.createServer(async (req, res) => {
} else if (action === 'update') { } else if (action === 'update') {
const form = await readForm(req); const form = await readForm(req);
const title = String(form.title || '').trim().slice(0, 200); const title = String(form.title || '').trim().slice(0, 200);
if (title) await pool.query('UPDATE tasks SET title = $1, updated_at = NOW() WHERE id = $2 AND created_by = $3', [title, id, userKey]); const priority = normalisePriority(form.priority);
if (title) await pool.query('UPDATE tasks SET title = $1, priority = $2, updated_at = NOW() WHERE id = $3 AND created_by = $4', [title, priority, id, userKey]);
} else if (action === 'delete') { } else if (action === 'delete') {
await pool.query('DELETE FROM tasks WHERE id = $1 AND created_by = $2', [id, userKey]); await pool.query('DELETE FROM tasks WHERE id = $1 AND created_by = $2', [id, userKey]);
} }
@ -182,15 +203,15 @@ const server = http.createServer(async (req, res) => {
} }
if (req.method === 'GET' && url.pathname === '/') { if (req.method === 'GET' && url.pathname === '/') {
const result = await pool.query('SELECT id, title, completed, created_at FROM tasks WHERE created_by = $1 ORDER BY created_at DESC', [userKey]); const result = await pool.query('SELECT id, title, completed, priority, created_at FROM tasks WHERE created_by = $1 ORDER BY created_at DESC', [userKey]);
const rows = result.rows.map(task => ` const rows = result.rows.map(task => `
<div class="task ${task.completed ? 'done' : ''}"> <div class="task ${task.completed ? 'done' : ''}">
<form method="post" action="/tasks/${task.id}/toggle"><button class="secondary" title="Toggle complete">${task.completed ? '✓' : '○'}</button></form> <form method="post" action="/tasks/${task.id}/toggle"><button class="secondary" title="Toggle complete">${task.completed ? '✓' : '○'}</button></form>
<div><form class="inline-edit" method="post" action="/tasks/${task.id}/update"><input name="title" maxlength="200" value="${esc(task.title)}" aria-label="Task title"><button class="secondary">Save</button></form><div class="muted" style="font-size:12px;margin-top:6px">Created ${esc(new Date(task.created_at).toLocaleString())}</div></div> <div><form class="inline-edit" method="post" action="/tasks/${task.id}/update"><input name="title" maxlength="200" value="${esc(task.title)}" aria-label="Task title"><select name="priority" aria-label="Priority">${priorityOptions(task.priority)}</select><button class="secondary">Save</button></form><span class="priority priority-${esc(task.priority)}">${esc(task.priority)}</span><div class="muted" style="font-size:12px;margin-top:6px">Created ${esc(new Date(task.created_at).toLocaleString())}</div></div>
<div class="actions"><form method="post" action="/tasks/${task.id}/delete"><button class="danger">Delete</button></form></div> <div class="actions"><form method="post" action="/tasks/${task.id}/delete"><button class="danger">Delete</button></form></div>
</div>`).join(''); </div>`).join('');
return send(res, 200, layout(session.user, `<div class="card"><form class="add" method="post" action="/tasks"><input name="title" maxlength="200" required placeholder="Add a task…" autofocus><button>Add task</button></form>${rows || '<div class="empty">No tasks yet. Add one above.</div>'}</div>`)); return send(res, 200, layout(session.user, `<div class="card"><form class="add" method="post" action="/tasks"><input name="title" maxlength="200" required placeholder="Add a task…" autofocus><select name="priority">${priorityOptions()}</select><button>Add task</button></form>${rows || '<div class="empty">No tasks yet. Add one above.</div>'}</div>`));
} }
return send(res, 404, 'Not found', 'text/plain; charset=utf-8'); return send(res, 404, 'Not found', 'text/plain; charset=utf-8');