const express = require('express'); const { Pool } = require('pg'); const app = express(); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const port = Number(process.env.PORT || 3000); const tags = new Set(['permits', 'kitchen', 'marketing']); const statuses = new Set(['todo', 'doing', 'done']); const seedTasks = [ ['county-permit', 'Submit county food permit', 'permits', '2026-08-04', 'doing'], ['fire-review', 'Book fire safety review', 'permits', '2026-08-08', 'todo'], ['vendor-license', 'Confirm street vendor license', 'permits', '2026-08-11', 'todo'], ['taco-test', 'Finish smoked mushroom taco test', 'kitchen', '2026-07-23', 'done'], ['cooler', 'Buy the service line cooler', 'kitchen', '2026-07-27', 'doing'], ['allergen-card', 'Print the allergen reference card', 'kitchen', '2026-07-29', 'todo'], ['supplier-call', 'Confirm first produce delivery', 'kitchen', '2026-07-31', 'todo'], ['menu-board', 'Approve the opening menu board', 'marketing', '2026-07-25', 'done'], ['photo-day', 'Shoot menu photos at golden hour', 'marketing', '2026-08-02', 'todo'], ['map-listing', 'Publish the weekly stop map', 'marketing', '2026-08-06', 'todo'], ['opening-flyer', 'Send the opening day flyer', 'marketing', '2026-08-12', 'todo'], ['friends-preview', 'Invite neighbors to the preview night', 'marketing', '2026-08-14', 'todo'], ]; app.use(express.json({ limit: '32kb' })); app.use(express.urlencoded({ extended: false })); const cleanTask = (input, partial = false) => { const task = {}; if (!partial || input.title !== undefined) { task.title = String(input.title || '').trim(); if (!task.title || task.title.length > 120) throw new Error('Title must be between 1 and 120 characters.'); } if (!partial || input.tag !== undefined) { task.tag = String(input.tag || ''); if (!tags.has(task.tag)) throw new Error('Tag must be permits, kitchen, or marketing.'); } if (!partial || input.dueDate !== undefined) { task.dueDate = String(input.dueDate || ''); if (!/^\d{4}-\d{2}-\d{2}$/.test(task.dueDate)) throw new Error('Due date must use YYYY-MM-DD.'); } if (!partial || input.status !== undefined) { task.status = String(input.status || 'todo'); if (!statuses.has(task.status)) throw new Error('Status must be todo, doing, or done.'); } return task; }; const escapeHtml = (value) => String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); const formatDate = (value) => value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10); const toTask = (row) => ({ id: String(row.id), title: row.title, tag: row.tag, dueDate: formatDate(row.due_date), status: row.status, }); async function seed() { for (const [seedKey, title, tag, dueDate, status] of seedTasks) { await pool.query( `insert into launch_tasks (seed_key, title, tag, due_date, status) values ($1, $2, $3, $4, $5) on conflict (seed_key) do nothing`, [seedKey, title, tag, dueDate, status], ); } } app.get('/health', async (_request, response) => { await pool.query('select 1'); response.json({ ok: true }); }); app.get('/api/tasks', async (request, response) => { const page = Math.max(1, Number.parseInt(request.query.page || '1', 10) || 1); const pageSize = Math.min(50, Math.max(1, Number.parseInt(request.query.pageSize || '10', 10) || 10)); const values = []; const conditions = []; if (request.query.status) { if (!statuses.has(request.query.status)) return response.status(400).json({ error: 'Unknown status.' }); values.push(request.query.status); conditions.push(`status = $${values.length}`); } if (request.query.tag) { if (!tags.has(request.query.tag)) return response.status(400).json({ error: 'Unknown tag.' }); values.push(request.query.tag); conditions.push(`tag = $${values.length}`); } if (request.query.search) { values.push(`%${String(request.query.search).slice(0, 80)}%`); conditions.push(`title ilike $${values.length}`); } const where = conditions.length ? `where ${conditions.join(' and ')}` : ''; const count = await pool.query(`select count(*)::int as total from launch_tasks ${where}`, values); values.push(pageSize, (page - 1) * pageSize); const result = await pool.query( `select id, title, tag, due_date, status from launch_tasks ${where} order by due_date, id limit $${values.length - 1} offset $${values.length}`, values, ); const total = count.rows[0].total; response.json({ tasks: result.rows.map(toTask), page: { number: page, size: pageSize, total, pages: Math.max(1, Math.ceil(total / pageSize)) }, }); }); app.post('/api/tasks', async (request, response) => { try { const task = cleanTask(request.body); const result = await pool.query( `insert into launch_tasks (title, tag, due_date, status) values ($1, $2, $3, $4) returning id, title, tag, due_date, status`, [task.title, task.tag, task.dueDate, task.status], ); response.status(201).json(toTask(result.rows[0])); } catch (error) { response.status(400).json({ error: error.message }); } }); app.patch('/api/tasks/:id', async (request, response) => { try { const updates = cleanTask(request.body, true); const entries = Object.entries(updates); if (!entries.length) return response.status(400).json({ error: 'Send at least one task field.' }); const columns = { title: 'title', tag: 'tag', dueDate: 'due_date', status: 'status' }; const values = entries.map(([, value]) => value); const set = entries.map(([key], index) => `${columns[key]} = $${index + 1}`); values.push(request.params.id); const result = await pool.query( `update launch_tasks set ${set.join(', ')}, updated_at = now() where id = $${values.length} returning id, title, tag, due_date, status`, values, ); if (!result.rowCount) return response.status(404).json({ error: 'Task not found.' }); response.json(toTask(result.rows[0])); } catch (error) { response.status(400).json({ error: error.message }); } }); app.delete('/api/tasks/:id', async (request, response) => { const result = await pool.query('delete from launch_tasks where id = $1', [request.params.id]); if (!result.rowCount) return response.status(404).json({ error: 'Task not found.' }); response.status(204).end(); }); app.get('/', async (_request, response) => { const result = await pool.query( `select id, title, tag, due_date, status from launch_tasks order by case status when 'doing' then 0 when 'todo' then 1 else 2 end, due_date, id`, ); const cards = result.rows.map((row) => `
${row.tag}${row.status}

${escapeHtml(row.title)}

Due ${formatDate(row.due_date)}

${row.seed_key ? '' : ``}
`).join(''); response.type('html').send(` Juniper Lunch Club launch board
Juniper Lunch Club

Opening day has a date.

${result.rowCount} moving pieces, one clear board. Get the permits, kitchen, and neighborhood ready together.

${cards}
`); }); seed() .then(() => app.listen(port, '0.0.0.0', () => console.log(`Launch board listening on ${port}`))) .catch((error) => { console.error('The launch board could not start.', error.message); process.exit(1); }); const shutdown = async () => { await pool.end(); process.exit(0); }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);