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

Tips: Klicka på en post i sökresultatet för att kopiera biljett-ID.

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(`

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 => ` ${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} ${r.pickup_code} ${r.id} `).join(""); res.send(`

Garderob – Personal

Väntar på tilldelning

${waitingRows}
TidGästÅtgärd

Tips: Be nästa gäst säga sitt nummer → skriv in galgnumret här → klicka Tilldela.

Aktiva incheckade (senaste)

${checkedRows}
TidGalgSlut-4HämtkodBiljett-ID

Utlämning

`); }); // 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: ${r.pickup_code}Biljett: ${r.id}
  • `).join("") || "
  • Inga aktiva träffar.
  • "; res.send(`

    Träffar för ${l4}

      ${list}

    Tillbaka

    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}`));

    AVVIKANDEÖPPETTIDER 8 Juni 15:00-22:00 9 Juni STÄNGT 20 Juni (midsommarafton) STÄNGT VÄLKOMNA ALLA ANDRA DAGAR!