Migrate tasks schema to v2 with priority
This commit is contained in:
parent
51435b9307
commit
35626f8087
41
server.js
41
server.js
@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
return String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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> <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> <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');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user