scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin)

This commit is contained in:
müde 2026-05-19 18:12:01 +02:00
commit e0898bea22
2 changed files with 5446 additions and 0 deletions

196
server/src/routes/admin.ts Normal file
View file

@ -0,0 +1,196 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { DB } from '../db.js';
const SESSION_COOKIE = 'wutz_admin';
function isAuthed(req: FastifyRequest): boolean {
const expected = process.env.ADMIN_PASSWORD;
if (!expected) return false;
return req.cookies?.[SESSION_COOKIE] === expected;
}
function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean {
if (isAuthed(req)) return true;
reply.code(401).send({ error: 'unauthorized' });
return false;
}
export function registerAdminRoutes(app: FastifyInstance, db: DB) {
app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => {
const expected = process.env.ADMIN_PASSWORD;
if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' });
if (req.body?.password !== expected) return reply.code(401).send({ error: 'wrong password' });
reply.setCookie(SESSION_COOKIE, expected, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
});
return { ok: true };
});
app.post('/admin/logout', async (_req, reply) => {
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
app.get('/admin/api/me', async (req) => ({ authed: isAuthed(req) }));
// ----- Drinks -----
app.get('/admin/api/drinks', async (req, reply) => {
if (!requireAuth(req, reply)) return;
return db
.prepare('SELECT id, name, price_cents, archived FROM drinks ORDER BY archived, id')
.all();
});
app.post<{ Body: { name: string; price_cents: number } }>(
'/admin/api/drinks',
async (req, reply) => {
if (!requireAuth(req, reply)) return;
const { name, price_cents } = req.body ?? ({} as any);
if (!name || !Number.isInteger(price_cents))
return reply.code(400).send({ error: 'invalid' });
const info = db
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
.run(name, price_cents);
return { id: Number(info.lastInsertRowid) };
}
);
app.patch<{ Params: { id: string }; Body: { name?: string; price_cents?: number; archived?: boolean } }>(
'/admin/api/drinks/:id',
async (req, reply) => {
if (!requireAuth(req, reply)) return;
const id = Number(req.params.id);
const { name, price_cents, archived } = req.body ?? {};
const sets: string[] = [];
const args: any[] = [];
if (name !== undefined) { sets.push('name = ?'); args.push(name); }
if (price_cents !== undefined) { sets.push('price_cents = ?'); args.push(price_cents); }
if (archived !== undefined) { sets.push('archived = ?'); args.push(archived ? 1 : 0); }
if (sets.length === 0) return { ok: true };
args.push(id);
db.prepare(`UPDATE drinks SET ${sets.join(', ')} WHERE id = ?`).run(...args);
return { ok: true };
}
);
// ----- Bars -----
app.get('/admin/api/bars', async (req, reply) => {
if (!requireAuth(req, reply)) return;
const bars = db.prepare('SELECT id, name, pfand_cents FROM bars ORDER BY id').all() as any[];
const drinkRows = db
.prepare('SELECT bar_id, drink_id, sort_order FROM bar_drinks ORDER BY sort_order, drink_id')
.all() as any[];
return bars.map(b => ({
...b,
drink_ids: drinkRows.filter(d => d.bar_id === b.id).map(d => d.drink_id),
}));
});
app.patch<{ Params: { id: string }; Body: { pfand_cents?: number; drink_ids?: number[] } }>(
'/admin/api/bars/:id',
async (req, reply) => {
if (!requireAuth(req, reply)) return;
const id = Number(req.params.id);
const { pfand_cents, drink_ids } = req.body ?? {};
db.transaction(() => {
if (pfand_cents !== undefined) {
db.prepare('UPDATE bars SET pfand_cents = ? WHERE id = ?').run(pfand_cents, id);
}
if (Array.isArray(drink_ids)) {
db.prepare('DELETE FROM bar_drinks WHERE bar_id = ?').run(id);
const ins = db.prepare(
'INSERT INTO bar_drinks (bar_id, drink_id, sort_order) VALUES (?, ?, ?)'
);
drink_ids.forEach((did, idx) => ins.run(id, did, idx));
}
})();
return { ok: true };
}
);
// ----- Stats -----
app.get('/admin/api/stats', async (req, reply) => {
if (!requireAuth(req, reply)) return;
const totals = db
.prepare(
`SELECT b.id AS bar_id, b.name AS bar_name,
COUNT(*) AS tx_count,
COALESCE(SUM(CASE WHEN crew = 0 THEN total_cents ELSE 0 END), 0) AS paid_cents,
COALESCE(SUM(CASE WHEN crew = 1 THEN 1 ELSE 0 END), 0) AS crew_count,
COALESCE(SUM(pfand_returns), 0) AS pfand_returns
FROM transactions t
JOIN bars b ON b.id = t.bar_id
GROUP BY b.id, b.name
ORDER BY b.id`
)
.all();
const perDrink = db
.prepare(
`SELECT d.id AS drink_id, d.name AS drink_name,
COALESCE(SUM(ti.qty), 0) AS sold_qty
FROM transaction_items ti
JOIN drinks d ON d.id = ti.drink_id
GROUP BY d.id, d.name
ORDER BY sold_qty DESC, d.id`
)
.all();
return { totals, per_drink: perDrink };
});
// ----- CSV export -----
app.get<{ Querystring: { what?: string } }>('/admin/api/export.csv', async (req, reply) => {
if (!requireAuth(req, reply)) return;
const what = req.query.what === 'items' ? 'items' : 'transactions';
let rows: any[];
let header: string[];
if (what === 'transactions') {
header = ['id', 'bar_id', 'bar_name', 'created_at', 'total_cents', 'crew', 'pfand_returns', 'client_ip', 'client_uuid'];
rows = db
.prepare(
`SELECT t.id, t.bar_id, b.name AS bar_name, t.created_at, t.total_cents,
t.crew, t.pfand_returns, t.client_ip, t.client_uuid
FROM transactions t JOIN bars b ON b.id = t.bar_id
ORDER BY t.id`
)
.all() as any[];
} else {
header = [
'transaction_id', 'bar_id', 'created_at', 'drink_id', 'drink_name',
'qty', 'unit_price_cents', 'pfand_cents_per_unit',
];
rows = db
.prepare(
`SELECT ti.transaction_id, t.bar_id, t.created_at, ti.drink_id, d.name AS drink_name,
ti.qty, ti.unit_price_cents, ti.pfand_cents_per_unit
FROM transaction_items ti
JOIN transactions t ON t.id = ti.transaction_id
JOIN drinks d ON d.id = ti.drink_id
ORDER BY ti.transaction_id, ti.id`
)
.all() as any[];
}
const csv = [header.join(',')]
.concat(rows.map(r => header.map(h => csvCell(r[h])).join(',')))
.join('\n');
reply
.header('Content-Type', 'text/csv; charset=utf-8')
.header('Content-Disposition', `attachment; filename="${what}.csv"`)
.send(csv);
});
}
function csvCell(v: unknown): string {
if (v === null || v === undefined) return '';
const s = String(v);
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}

132
server/src/routes/public.ts Normal file
View file

@ -0,0 +1,132 @@
import type { FastifyInstance } from 'fastify';
import type { DB } from '../db.js';
import type {
Bar,
BarConfig,
CreateTransactionRequest,
CreateTransactionResponse,
Drink,
} from '@wutzcalc/shared';
export function registerPublicRoutes(app: FastifyInstance, db: DB) {
app.get('/api/bars', async () => {
const rows = db.prepare('SELECT id, name, pfand_cents FROM bars ORDER BY id').all() as Bar[];
return rows;
});
app.get<{ Querystring: { bar: string } }>('/api/config', async (req, reply) => {
const barId = Number(req.query.bar);
if (!Number.isInteger(barId)) return reply.code(400).send({ error: 'bar required' });
const bar = db
.prepare('SELECT id, name, pfand_cents FROM bars WHERE id = ?')
.get(barId) as Bar | undefined;
if (!bar) return reply.code(404).send({ error: 'bar not found' });
const drinks = db
.prepare(
`SELECT d.id, d.name, d.price_cents, d.archived
FROM drinks d
JOIN bar_drinks bd ON bd.drink_id = d.id
WHERE bd.bar_id = ? AND d.archived = 0
ORDER BY bd.sort_order, d.id`
)
.all(barId) as Drink[];
const config: BarConfig = { bar, drinks };
return config;
});
app.post<{ Body: CreateTransactionRequest }>('/api/transactions', async (req, reply) => {
const body = req.body ?? ({} as CreateTransactionRequest);
const { client_uuid, bar_id, crew, items } = body;
const pfand_returns = Math.max(0, Math.floor(body.pfand_returns ?? 0));
if (!client_uuid || typeof client_uuid !== 'string') {
return reply.code(400).send({ error: 'client_uuid required' });
}
if (!Number.isInteger(bar_id)) {
return reply.code(400).send({ error: 'bar_id required' });
}
if (!Array.isArray(items)) {
return reply.code(400).send({ error: 'items required' });
}
if (items.length === 0 && pfand_returns === 0) {
return reply.code(400).send({ error: 'empty transaction' });
}
const existing = db
.prepare('SELECT id, total_cents FROM transactions WHERE client_uuid = ?')
.get(client_uuid) as { id: number; total_cents: number } | undefined;
if (existing) {
const res: CreateTransactionResponse = { id: existing.id, total_cents: existing.total_cents };
return res;
}
const bar = db
.prepare('SELECT id, pfand_cents FROM bars WHERE id = ?')
.get(bar_id) as { id: number; pfand_cents: number } | undefined;
if (!bar) return reply.code(404).send({ error: 'bar not found' });
const drinkStmt = db.prepare(
`SELECT d.id, d.price_cents
FROM drinks d
JOIN bar_drinks bd ON bd.drink_id = d.id
WHERE bd.bar_id = ? AND d.id = ? AND d.archived = 0`
);
let total = 0;
const priced: Array<{
drink_id: number;
qty: number;
unit_price_cents: number;
pfand_cents_per_unit: number;
}> = [];
for (const item of items) {
if (!Number.isInteger(item.drink_id) || !Number.isInteger(item.qty) || item.qty <= 0) {
return reply.code(400).send({ error: 'invalid item' });
}
const drink = drinkStmt.get(bar_id, item.drink_id) as
| { id: number; price_cents: number }
| undefined;
if (!drink) return reply.code(400).send({ error: `drink ${item.drink_id} not at bar` });
const lineUnit = drink.price_cents + bar.pfand_cents;
total += lineUnit * item.qty;
priced.push({
drink_id: drink.id,
qty: item.qty,
unit_price_cents: drink.price_cents,
pfand_cents_per_unit: bar.pfand_cents,
});
}
total -= bar.pfand_cents * pfand_returns;
const paidTotal = crew ? 0 : total;
const clientIp = req.ip ?? null;
const insertTx = db.prepare(
`INSERT INTO transactions (bar_id, total_cents, crew, pfand_returns, client_ip, client_uuid)
VALUES (?, ?, ?, ?, ?, ?)`
);
const insertItem = db.prepare(
`INSERT INTO transaction_items
(transaction_id, drink_id, qty, unit_price_cents, pfand_cents_per_unit, is_return)
VALUES (?, ?, ?, ?, ?, 0)`
);
const txId = db.transaction(() => {
const info = insertTx.run(bar_id, paidTotal, crew ? 1 : 0, pfand_returns, clientIp, client_uuid);
const id = Number(info.lastInsertRowid);
for (const p of priced) {
insertItem.run(id, p.drink_id, p.qty, p.unit_price_cents, p.pfand_cents_per_unit);
}
return id;
})();
const res: CreateTransactionResponse = { id: txId, total_cents: paidTotal };
return res;
});
}