import express from "express";
import dotenv from "dotenv";
import Database from "better-sqlite3";
import { nanoid } from "nanoid";
import twilio from "twilio";
import path from "path";
import { fileURLToPath } from "url";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// ---- DB init ----
const db = new Database(process.env.DATABASE_URL || "./cloakroom.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
hanger_number TEXT NOT NULL,
phone TEXT NOT NULL,
phone_last4 TEXT NOT NULL,
pickup_code TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'checked_in', -- 'checked_in' | 'picked_up' | 'cancelled'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_last4 ON tickets(phone_last4);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_hanger ON tickets(hanger_number, status);
`);
// ---- SMS client ----
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
const FROM = process.env.TWILIO_FROM;
// ---- Helpers ----
function normalizePhone(no) {
// En enkel normalisering: ta bort mellanslag och bindestreck
return no.replace(/\s|-/g, "");
}
function last4(no) {
const digits = no.replace(/\D/g, "");
return digits.slice(-4);
}
function generatePickupCode() {
// 6 tecken: blandning av siffror och stora bokstäver (lätt att läsa)
return nanoid(6).toUpperCase().replace(/O/g, "X").replace(/0/g, "7");
}
async function sendSms(to, text) {
if (!FROM) throw new Error("TWILIO_FROM is missing");
return twilioClient.messages.create({ from: FROM, to, body: text });
}
// ---- Views (enkla HTML-formulär för personal) ----
app.get("/", (_req, res) => {
res.send(`
Garderob – Personal
Incheckning
Uthämtning
Markera som utlämnad
Byggd för snabb bemanning: tangentbordsnavigering, enkla fält, minimal klick.
`); }); // ---- API Endpoints ---- // Incheckning: spara + SMS app.post("/checkin", async (req, res) => { try { const hanger = String(req.body.hanger_number || "").trim(); const phoneRaw = String(req.body.phone || "").trim(); if (!hanger || !phoneRaw) return res.status(400).send("Missing hanger_number or phone"); const phone = normalizePhone(phoneRaw); const last = last4(phone); const code = generatePickupCode(); const id = nanoid(12); // Kolla om galgen redan är aktivt använd const exists = db.prepare(`SELECT id FROM tickets WHERE hanger_number = ? AND status = 'checked_in'`).get(hanger); if (exists) return res.status(409).send(`Galg ${hanger} används redan för en aktiv biljett.`); db.prepare(` INSERT INTO tickets (id, hanger_number, phone, phone_last4, pickup_code) VALUES (?, ?, ?, ?, ?) `).run(id, hanger, phone, last, code); const smsText = `Tack! Din jacka är incheckad.\n` + `Hämtkod: ${code}\n` + `Visa denna kod vid utlämning.`; await sendSms(phone, smsText); res.send(`Incheckad ✅
Biljett-ID: ${id}
Galg: ${hanger}
SMS skickat till ${phone} med hämtkod
import express from "express";
import dotenv from "dotenv";
import Database from "better-sqlite3";
import { nanoid } from "nanoid";
import twilio from "twilio";
import path from "path";
import { fileURLToPath } from "url";
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
/* ---------- DB ---------- */
const db = new Database(process.env.DATABASE_URL || "./cloakroom.db");
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
phone TEXT NOT NULL,
phone_last4 TEXT NOT NULL,
hanger_number TEXT, -- null tills tilldelad
pickup_code TEXT, -- skapas vid tilldelning
status TEXT NOT NULL DEFAULT 'waiting', -- 'waiting' | 'checked_in' | 'picked_up' | 'cancelled'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tickets_last4 ON tickets(phone_last4);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_hanger ON tickets(hanger_number, status) WHERE status = 'checked_in';
`);
function nowUpdate(id) {
db.prepare(`UPDATE tickets SET updated_at = datetime('now') WHERE id = ?`).run(id);
}
/* ---------- SMS ---------- */
const tw = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
async function sendSms(to, body) {
if (!process.env.TWILIO_FROM) throw new Error("TWILIO_FROM missing");
return tw.messages.create({ from: process.env.TWILIO_FROM, to, body });
}
/* ---------- Helpers ---------- */
const normalizePhone = p => String(p).replace(/\s|-/g, "");
const last4 = p => String(p).replace(/\D/g, "").slice(-4);
function generatePickupCode() {
// 6 tecken, lättläst
return nanoid(6).toUpperCase().replace(/O/g, "X").replace(/0/g, "7");
}
const maskPhone = p => {
const d = p.replace(/\D/g, "");
return d.length > 4 ? `•••${d.slice(-4)}` : `•••${d}`;
};
/* ---------- Minimal stil ---------- */
const css = `
body{font-family:system-ui,Segoe UI,Arial;max-width:720px;margin:24px auto;padding:0 16px}
h1{font-size:22px;margin:8px 0}
h2{font-size:18px;margin:6px 0}
form{display:grid;gap:12px;margin:12px 0}
input,button{font-size:16px;padding:10px}
.card{border:1px solid #e6e6e6;border-radius:12px;padding:16px;margin:12px 0}
.muted{color:#666;font-size:14px}
table{width:100%;border-collapse:collapse}
th,td{padding:8px;border-bottom:1px solid #eee;text-align:left;font-size:15px}
.row{display:grid;gap:8px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.ok{color:#0a7c2f}
.warn{color:#b15a00}
.err{color:#b00020}
`;
/* ---------- Gästvy ---------- */
// GET /guest – formulär där gäster knappar in sitt nummer i kön
app.get("/guest", (_req, res) => {
res.send(`
${new Date(r.created_at).toLocaleTimeString()}
${maskPhone(r.phone)} (${r.phone_last4})
`).join("") || `Ingen väntar just nu. `;
const checkedRows = checkedIn.map(r => `
${new Date(r.created_at).toLocaleTimeString()}
${r.hanger_number}
${r.phone_last4}
`).join("");
res.send(`
`);
});
// POST /assign – personal tilldelar galgnummer till väntande gäst → skickar SMS
app.post("/assign", async (req, res) => {
try {
const ticketId = String(req.body.ticket_id || "").trim();
const hanger = String(req.body.hanger_number || "").trim();
if (!ticketId || !hanger) return res.status(400).send("Saknar biljett eller galgnummer.");
const t = db.prepare(`SELECT * FROM tickets WHERE id = ?`).get(ticketId);
if (!t) return res.status(404).send("Biljett hittas inte.");
if (t.status !== "waiting") return res.status(409).send("Biljetten är inte i väntande status.");
// kontrollera att galg inte redan är aktiv
const clash = db.prepare(`SELECT id FROM tickets WHERE hanger_number = ? AND status = 'checked_in'`).get(hanger);
if (clash) return res.status(409).send(`Galg ${hanger} används redan.`);
const code = generatePickupCode();
db.prepare(`
UPDATE tickets
SET hanger_number = ?, pickup_code = ?, status = 'checked_in', updated_at = datetime('now')
WHERE id = ?
`).run(hanger, code, ticketId);
await sendSms(t.phone, `Din jacka är inlämnad. Hämtkod: ${code}. Visa denna kod vid utlämning. – Garderoben`);
res.redirect("/staff");
} catch (e) {
console.error(e);
res.status(500).send("Kunde inte tilldela. Kontrollera SMS-inställningar.");
}
});
/* ---------- Sök & utlämning ---------- */
// GET /lookup – personal söker med sista 4 siffror
app.get("/lookup", (req, res) => {
const l4 = String(req.query.last4 || "").replace(/\D/g, "");
if (l4.length !== 4) return res.status(400).send("Ange exakt 4 siffror.");
const rows = db.prepare(`
SELECT id, hanger_number, pickup_code, created_at
FROM tickets
WHERE phone_last4 = ? AND status = 'checked_in'
ORDER BY created_at ASC
`).all(l4);
const list = rows.map(r => `
Galg: ${r.hanger_number} – Hämtkod:
`).join("") || "Inga aktiva träffar. ";
res.send(`
Garderob – Gäst
Ange ditt telefonnummer. När du kommer fram till disken tilldelas du ett garderobsnummer och får ett SMS med hämtkod.
Dina sista fyra siffror används vid utlämning.
Integritet: Ditt nummer används enbart för garderoben och raderas efter eventet.
`); }); // POST /guest – lägg till en väntande biljett app.post("/guest", (req, res) => { const phoneRaw = String(req.body.phone || "").trim(); if (!phoneRaw) return res.status(400).send("Ange telefonnummer."); const phone = normalizePhone(phoneRaw); const l4 = last4(phone); if (l4.length !== 4) return res.status(400).send("Ange ett giltigt telefonnummer (måste innehålla minst 4 siffror)."); const id = nanoid(12); db.prepare(` INSERT INTO tickets (id, phone, phone_last4, status) VALUES (?, ?, ?, 'waiting') `).run(id, phone, l4); res.send(`Klart! ✅
Du står nu i kö. När du kommer fram till disken tilldelas du ett garderobsnummer och får ett SMS.
Vid utlämning uppger du de 4 sista siffrorna i ditt nummer.
Tillbaka `); }); /* ---------- Personalvy ---------- */ // GET /staff – se väntande gäster + tilldela galgnummer (skickar SMS) app.get("/staff", (_req, res) => { const waiting = db.prepare(` SELECT id, phone, phone_last4, created_at FROM tickets WHERE status = 'waiting' ORDER BY created_at ASC `).all(); const checkedIn = db.prepare(` SELECT id, phone_last4, hanger_number, pickup_code, created_at FROM tickets WHERE status = 'checked_in' ORDER BY created_at DESC LIMIT 20 `).all(); const waitingRows = waiting.map(r => `${r.pickup_code}
${r.id}
Garderob – Personal
Väntar på tilldelning
Tid | Gäst | Åtgärd |
---|
Tips: Be nästa gäst säga sitt nummer → skriv in galgnumret här → klicka Tilldela.
Aktiva incheckade (senaste)
Tid | Galg | Slut-4 | Hämtkod | Biljett-ID |
---|
Utlämning
${r.pickup_code}
– Biljett: ${r.id}
Träffar för ${l4}
- ${list}
Tips: Be gästen uppge hämtkoden om flera träffar finns.
`); }); // POST /pickup – markera utlämnad (valfri kodverifiering) app.post("/pickup", (req, res) => { const ticketId = String(req.body.ticket_id || "").trim(); const providedCode = String(req.body.pickup_code || "").trim(); const ticket = db.prepare(`SELECT * FROM tickets WHERE id = ?`).get(ticketId); if (!ticket) return res.status(404).send("Biljett hittas inte."); if (ticket.status !== "checked_in") return res.status(409).send("Biljetten är redan utlämnad/avslutad."); if (providedCode && providedCode !== ticket.pickup_code) { return res.status(403).send("Fel hämtkod."); } db.prepare(` UPDATE tickets SET status = 'picked_up', updated_at = datetime('now') WHERE id = ? `).run(ticketId); res.redirect("/staff"); }); /* ---------- Rot/404 ---------- */ app.get("/", (_req, res) => res.redirect("/staff")); app.use((_req, res) => res.status(404).send("Hittas inte")); const port = process.env.PORT || 3000; app.listen(port, () => console.log(`Cloakroom running: http://localhost:${port}`));