scaffold festival drink tracker (pnpm workspace, Fastify + SQLite, Preact tablet UI, admin)
This commit is contained in:
parent
cf33e6ea04
commit
e0898bea22
34 changed files with 5446 additions and 0 deletions
42
server/migrations/001_init.sql
Normal file
42
server/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
CREATE TABLE bars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
pfand_cents INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE drinks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
price_cents INTEGER NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE bar_drinks (
|
||||
bar_id INTEGER NOT NULL REFERENCES bars(id) ON DELETE CASCADE,
|
||||
drink_id INTEGER NOT NULL REFERENCES drinks(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (bar_id, drink_id)
|
||||
);
|
||||
|
||||
CREATE TABLE transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bar_id INTEGER NOT NULL REFERENCES bars(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
total_cents INTEGER NOT NULL,
|
||||
crew INTEGER NOT NULL DEFAULT 0,
|
||||
client_ip TEXT,
|
||||
client_uuid TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE transaction_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
drink_id INTEGER NOT NULL REFERENCES drinks(id),
|
||||
qty INTEGER NOT NULL,
|
||||
unit_price_cents INTEGER NOT NULL,
|
||||
pfand_cents_per_unit INTEGER NOT NULL,
|
||||
is_return INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tx_bar_created ON transactions(bar_id, created_at);
|
||||
CREATE INDEX idx_txitem_tx ON transaction_items(transaction_id);
|
||||
23
server/migrations/002_seed.sql
Normal file
23
server/migrations/002_seed.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
INSERT INTO bars (name, pfand_cents) VALUES
|
||||
('Hauptbar', 200),
|
||||
('Cocktailbar', 200),
|
||||
('Außenbar', 200);
|
||||
|
||||
INSERT INTO drinks (name, price_cents) VALUES
|
||||
('Bier 0.5l', 400),
|
||||
('Bier 0.3l', 300),
|
||||
('Radler', 400),
|
||||
('Wasser', 200),
|
||||
('Cola', 300),
|
||||
('Aperol Spritz', 700),
|
||||
('Gin Tonic', 800),
|
||||
('Schnaps', 250);
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 1, id, id FROM drinks WHERE name IN ('Bier 0.5l', 'Bier 0.3l', 'Radler', 'Wasser', 'Cola', 'Schnaps');
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 2, id, id FROM drinks WHERE name IN ('Aperol Spritz', 'Gin Tonic', 'Wasser', 'Cola');
|
||||
|
||||
INSERT INTO bar_drinks (bar_id, drink_id, sort_order)
|
||||
SELECT 3, id, id FROM drinks WHERE name IN ('Bier 0.5l', 'Wasser', 'Cola', 'Schnaps');
|
||||
1
server/migrations/003_pfand_returns.sql
Normal file
1
server/migrations/003_pfand_returns.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE transactions ADD COLUMN pfand_returns INTEGER NOT NULL DEFAULT 0;
|
||||
25
server/package.json
Normal file
25
server/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@wutzcalc/shared": "workspace:*",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"fastify": "^4.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/node": "^20.12.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
45
server/src/db.ts
Normal file
45
server/src/db.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export type DB = Database.Database;
|
||||
|
||||
export function openDb(path: string): DB {
|
||||
const db = new Database(path);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function runMigrations(db: DB) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
|
||||
);
|
||||
const migrationsDir = join(__dirname, '..', 'migrations');
|
||||
const files = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||||
const insert = db.prepare('INSERT INTO _migrations (name) VALUES (?)');
|
||||
for (const f of files) {
|
||||
if (applied.has(f)) continue;
|
||||
const sql = readFileSync(join(migrationsDir, f), 'utf8');
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(sql);
|
||||
insert.run(f);
|
||||
db.exec('COMMIT');
|
||||
console.log(`migration applied: ${f}`);
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
58
server/src/index.ts
Normal file
58
server/src/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import Fastify from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { openDb } from './db.js';
|
||||
import { registerPublicRoutes } from './routes/public.js';
|
||||
import { registerAdminRoutes } from './routes/admin.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3000);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
const DB_PATH = process.env.DB_PATH ?? join(process.cwd(), 'wutz.db');
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const db = openDb(DB_PATH);
|
||||
|
||||
registerPublicRoutes(app, db);
|
||||
registerAdminRoutes(app, db);
|
||||
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
|
||||
// Static client (built by Vite into client/dist)
|
||||
const clientDist = join(__dirname, '..', '..', 'client', 'dist');
|
||||
if (existsSync(clientDist)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: clientDist,
|
||||
prefix: '/',
|
||||
wildcard: false,
|
||||
});
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api') || req.url.startsWith('/admin/api')) {
|
||||
reply.code(404).send({ error: 'not found' });
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/admin')) {
|
||||
reply.sendFile('admin.html');
|
||||
return;
|
||||
}
|
||||
reply.sendFile('index.html');
|
||||
});
|
||||
} else {
|
||||
app.log.warn(`client dist not found at ${clientDist} — run \`pnpm --filter client build\``);
|
||||
}
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
app.log.info(`listening on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
196
server/src/routes/admin.ts
Normal file
196
server/src/routes/admin.ts
Normal 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
132
server/src/routes/public.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
11
server/tsconfig.json
Normal file
11
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue