diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml index 06bef6b..c438641 100644 --- a/.gitea/workflows/container.yml +++ b/.gitea/workflows/container.yml @@ -25,6 +25,10 @@ jobs: podman run --rm --interactive docker.io/library/node:24-alpine node --check - < database.js podman run --rm --interactive docker.io/library/node:24-alpine node --check - < public/app.js podman run --rm --interactive docker.io/library/node:24-alpine node --check - < public/admin.js + podman run --rm \ + --volume "$PWD:/app:ro" \ + --workdir /app \ + docker.io/library/node:24-alpine npm test publish: name: Build and push diff --git a/README.md b/README.md index a682e16..d44d866 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ Die Übersicht zeigt: - wann die Antwort zuletzt gespeichert beziehungsweise geändert wurde - die erwartete Gesamtzahl der kommenden Gäste +Über „Antwort setzen“ beziehungsweise „Antwort ändern“ kann die Zu- oder Absage im Adminbereich stellvertretend für einen Gast gespeichert werden. Bei einer Zusage mit Begleitung wird auch deren Name erfasst. + Über „CSV herunterladen“ lässt sich die vollständige Liste für Excel, Numbers oder LibreOffice exportieren. Eine Öffnung wird gezählt, sobald das JavaScript der RSVP-Seite die Einladung abruft. Manche besonders aktive Link-Vorschau-Bots können theoretisch ebenfalls eine Öffnung erzeugen. ## Persönlichen Einladungslink anlegen diff --git a/public/admin.css b/public/admin.css index 3b45583..4918eb7 100644 --- a/public/admin.css +++ b/public/admin.css @@ -7,8 +7,8 @@ .admin-card { padding: 30px; background: rgba(255,253,249,.94); border: 1px solid rgba(0,104,128,.1); border-radius: 22px; box-shadow: 0 16px 45px rgba(15,78,88,.1); } .admin-card h2 { font-size: 1.8rem; } .admin-card label { display: block; margin: 1.2rem 0 .4rem; color: var(--ink-dark); font-weight: 700; } -.admin-card input, .admin-card textarea { width: 100%; padding: .85rem 1rem; color: var(--ink-dark); background: white; border: 1.5px solid var(--line); border-radius: 12px; outline: none; resize: vertical; } -.admin-card input:focus, .admin-card textarea:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24,185,187,.15); } +.admin-card input, .admin-card textarea, .response-dialog input, .response-dialog select { width: 100%; padding: .85rem 1rem; color: var(--ink-dark); background: white; border: 1.5px solid var(--line); border-radius: 12px; outline: none; resize: vertical; } +.admin-card input:focus, .admin-card textarea:focus, .response-dialog input:focus, .response-dialog select:focus { border-color: var(--turquoise); box-shadow: 0 0 0 3px rgba(24,185,187,.15); } .login-card { width: min(480px, 100%); margin: 10vh auto 0; text-align: center; } .login-card .state-icon { margin-bottom: 1rem; } .login-card label { text-align: left; } @@ -41,6 +41,15 @@ td:first-child { color: var(--ink-dark); font-weight: 700; } .status.alone, .status.with_someone { color: #167039; background: #e6f7e8; } .status.not_coming { color: #9a462d; background: #fcedea; } .link-button { width: auto; margin: 0; padding: .42rem .65rem; font-size: .68rem; } +.response-dialog { width: min(480px, calc(100% - 32px)); padding: 28px; color: var(--ink-dark); background: var(--white); border: 0; border-radius: 22px; box-shadow: var(--shadow); } +.response-dialog::backdrop { background: rgba(7, 85, 107, .35); backdrop-filter: blur(3px); } +.response-dialog h2 { margin: 0; font: 400 2rem/1.1 Georgia, serif; } +.response-dialog label { display: block; margin: 1.2rem 0 .4rem; font-weight: 700; } +.response-dialog .eyebrow { margin: 0 0 .5rem; } +.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } +.dialog-close { width: 42px; margin: 0; padding: .35rem; color: var(--ink); background: transparent; font-size: 1.6rem; line-height: 1; } +.dialog-close:hover { color: white; } +.dialog-actions { display: flex; justify-content: flex-end; gap: .7rem; margin-top: 1.5rem; } code { padding: .1em .3em; background: #edf5f3; border-radius: 4px; } @media (max-width: 760px) { diff --git a/public/admin.html b/public/admin.html index 0d479b3..1ec208d 100644 --- a/public/admin.html +++ b/public/admin.html @@ -89,6 +89,7 @@ Zuletzt geöffnet Zuletzt gespeichert Link + Aktion @@ -97,6 +98,37 @@ + + +
+
+
+

Antwort bearbeiten

+

Gast

+
+ +
+ + + + + + + +
+ + +
+
+
diff --git a/public/admin.js b/public/admin.js index d479bff..da692ce 100644 --- a/public/admin.js +++ b/public/admin.js @@ -13,9 +13,18 @@ const createdLinks = document.querySelector("#created-links"); const createdList = document.querySelector("#created-list"); const responseBody = document.querySelector("#response-body"); const tableEmpty = document.querySelector("#table-empty"); +const responseDialog = document.querySelector("#response-dialog"); +const responseEditorForm = document.querySelector("#response-editor-form"); +const responseDialogTitle = document.querySelector("#response-dialog-title"); +const adminAnswer = document.querySelector("#admin-answer"); +const adminCompanionWrap = document.querySelector("#admin-companion-wrap"); +const adminCompanionName = document.querySelector("#admin-companion-name"); +const responseEditorError = document.querySelector("#response-editor-error"); +const responseSaveButton = document.querySelector("#response-save-button"); let adminKey = sessionStorage.getItem("inviteAdminKey") || ""; let lastCreated = []; +let editingToken = ""; const answerLabels = { not_coming: "Kommt nicht", @@ -50,6 +59,23 @@ function cell(row, text) { return td; } +function toggleAdminCompanion() { + const needsCompanion = adminAnswer.value === "with_someone"; + adminCompanionWrap.hidden = !needsCompanion; + adminCompanionName.required = needsCompanion; +} + +function openResponseEditor(invite) { + editingToken = invite.token; + responseDialogTitle.textContent = invite.guestName; + adminAnswer.value = invite.answer || ""; + adminCompanionName.value = invite.companionName || ""; + responseEditorError.hidden = true; + toggleAdminCompanion(); + responseDialog.showModal(); + adminAnswer.focus(); +} + function renderTable(invites) { responseBody.replaceChildren(); tableEmpty.hidden = invites.length > 0; @@ -74,6 +100,14 @@ function renderTable(invites) { copyButton.addEventListener("click", () => copyText(invite.link, copyButton)); linkCell.append(copyButton); row.append(linkCell); + const actionCell = document.createElement("td"); + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "link-button secondary"; + editButton.textContent = invite.answer ? "Antwort ändern" : "Antwort setzen"; + editButton.addEventListener("click", () => openResponseEditor(invite)); + actionCell.append(editButton); + row.append(actionCell); responseBody.append(row); } } @@ -169,6 +203,59 @@ document.querySelector("#logout-button").addEventListener("click", () => { window.location.reload(); }); +adminAnswer.addEventListener("change", () => { + responseEditorError.hidden = true; + toggleAdminCompanion(); + if (!adminCompanionWrap.hidden) adminCompanionName.focus(); +}); + +function closeResponseDialog() { + responseDialog.close(); + editingToken = ""; +} + +document.querySelector("#response-dialog-close").addEventListener("click", closeResponseDialog); +document.querySelector("#response-dialog-cancel").addEventListener("click", closeResponseDialog); + +responseEditorForm.addEventListener("submit", async event => { + event.preventDefault(); + responseEditorError.hidden = true; + const answer = adminAnswer.value; + const companionName = adminCompanionName.value.trim(); + + if (!answer) { + responseEditorError.textContent = "Bitte wähle eine Antwort aus."; + responseEditorError.hidden = false; + return; + } + if (answer === "with_someone" && !companionName) { + responseEditorError.textContent = "Bitte gib den Namen der Begleitung an."; + responseEditorError.hidden = false; + adminCompanionName.focus(); + return; + } + + responseSaveButton.disabled = true; + responseSaveButton.textContent = "Wird gespeichert …"; + try { + const response = await fetch(`/api/admin/invites/${encodeURIComponent(editingToken)}/rsvp`, { + method: "PATCH", + headers: authHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify({ answer, companionName }) + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Antwort konnte nicht gespeichert werden."); + closeResponseDialog(); + await loadDashboard(); + } catch (error) { + responseEditorError.textContent = error.message; + responseEditorError.hidden = false; + } finally { + responseSaveButton.disabled = false; + responseSaveButton.textContent = "Antwort speichern"; + } +}); + document.querySelector("#csv-button").addEventListener("click", async event => { event.currentTarget.disabled = true; try { diff --git a/server.js b/server.js index e349103..2310deb 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,7 @@ const ORIGIN = (process.env.ORIGIN || `http://localhost:${PORT}`).replace(/\/$/, const PUBLIC_DIR = path.join(__dirname, "public"); const INVITATION_PDF = process.env.INVITATION_PDF || path.join(__dirname, "Einladung.pdf"); const INVITATION_IMAGE = process.env.INVITATION_IMAGE || path.join(__dirname, "private-assets", "einladung.jpg"); +const VALID_ANSWERS = new Set(["not_coming", "alone", "with_someone"]); const MIME_TYPES = { ".html": "text/html; charset=utf-8", @@ -68,6 +69,22 @@ function createToken() { return crypto.randomBytes(18).toString("base64url"); } +function normalizeRsvp(payload) { + if (!VALID_ANSWERS.has(payload.answer)) { + return { error: "Bitte wähle eine Antwort aus." }; + } + + const companionName = String(payload.companionName || "").trim().slice(0, 100); + if (payload.answer === "with_someone" && !companionName) { + return { error: "Bitte verrate uns den Namen der Begleitung." }; + } + + return { + answer: payload.answer, + companionName: payload.answer === "with_someone" ? companionName : "" + }; +} + function safeInvite(invite) { return { guestName: invite.guestName || "", @@ -172,20 +189,13 @@ const server = http.createServer(async (request, response) => { const rsvpMatch = route.match(/^\/api\/invites\/([A-Za-z0-9_-]+)\/rsvp$/); if (request.method === "POST" && rsvpMatch) { const payload = await readJson(request); - const validAnswers = new Set(["not_coming", "alone", "with_someone"]); - if (!validAnswers.has(payload.answer)) { - return sendJson(response, 400, { error: "Bitte wähle eine Antwort aus." }); - } - - const companionName = String(payload.companionName || "").trim().slice(0, 100); - if (payload.answer === "with_someone" && !companionName) { - return sendJson(response, 400, { error: "Bitte verrate uns den Namen deiner Begleitung." }); - } + const rsvp = normalizeRsvp(payload); + if (rsvp.error) return sendJson(response, 400, { error: rsvp.error }); const invite = saveRsvp( rsvpMatch[1], - payload.answer, - payload.answer === "with_someone" ? companionName : "", + rsvp.answer, + rsvp.companionName, new Date().toISOString() ); if (!invite) { @@ -207,6 +217,23 @@ const server = http.createServer(async (request, response) => { return sendJson(response, 201, { invites: created }); } + const adminRsvpMatch = route.match(/^\/api\/admin\/invites\/([A-Za-z0-9_-]+)\/rsvp$/); + if (request.method === "PATCH" && adminRsvpMatch) { + if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); + const payload = await readJson(request); + const rsvp = normalizeRsvp(payload); + if (rsvp.error) return sendJson(response, 400, { error: rsvp.error }); + + const invite = saveRsvp( + adminRsvpMatch[1], + rsvp.answer, + rsvp.companionName, + new Date().toISOString() + ); + if (!invite) return sendJson(response, 404, { error: "Einladung nicht gefunden." }); + return sendJson(response, 200, { ok: true, invite: adminInvite(invite) }); + } + if (request.method === "GET" && route === "/api/admin/invites") { if (!isAdmin(request, url)) return sendJson(response, 401, { error: "Nicht autorisiert." }); const invites = listInvites().map(adminInvite); @@ -265,7 +292,11 @@ const server = http.createServer(async (request, response) => { } }); -server.listen(PORT, HOST, () => { - console.log(`Invitation server listening on ${ORIGIN}`); - if (!ADMIN_KEY && !TRUST_AUTHENTIK) console.warn("No admin authentication is configured; admin endpoints are disabled."); -}); +if (require.main === module) { + server.listen(PORT, HOST, () => { + console.log(`Invitation server listening on ${ORIGIN}`); + if (!ADMIN_KEY && !TRUST_AUTHENTIK) console.warn("No admin authentication is configured; admin endpoints are disabled."); + }); +} + +module.exports = { server }; diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..2b2d89c --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,78 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { after, before, test } = require("node:test"); + +const testDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "birthday-rsvp-test-")); +process.env.ADMIN_KEY = "test-admin-key"; +process.env.DATABASE_FILE = path.join(testDirectory, "invites.db"); +process.env.ORIGIN = "http://127.0.0.1"; + +const { createInvites, getInvite } = require("../database"); +const { server } = require("../server"); + +const token = "admin-rsvp-test-token"; +let baseUrl; + +before(async () => { + createInvites([{ token, guestName: "Test Guest" }]); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise(resolve => server.close(resolve)); + fs.rmSync(testDirectory, { recursive: true, force: true }); +}); + +async function patchRsvp(payload, authenticated = true, inviteToken = token) { + const headers = { "Content-Type": "application/json" }; + if (authenticated) headers.Authorization = "Bearer test-admin-key"; + const response = await fetch(`${baseUrl}/api/admin/invites/${inviteToken}/rsvp`, { + method: "PATCH", + headers, + body: JSON.stringify(payload) + }); + return { response, body: await response.json() }; +} + +test("an admin can set and update an RSVP on behalf of a guest", async () => { + const unauthorized = await patchRsvp({ answer: "alone" }, false); + assert.equal(unauthorized.response.status, 401); + assert.equal(getInvite(token).answer, null); + + const missingCompanion = await patchRsvp({ answer: "with_someone" }); + assert.equal(missingCompanion.response.status, 400); + assert.equal(getInvite(token).answer, null); + + const saved = await patchRsvp({ answer: "with_someone", companionName: " Companion Guest " }); + assert.equal(saved.response.status, 200); + assert.equal(saved.body.invite.answer, "with_someone"); + assert.equal(saved.body.invite.companionName, "Companion Guest"); + assert.ok(saved.body.invite.lastSavedAt); + + const dashboardResponse = await fetch(`${baseUrl}/api/admin/invites`, { + headers: { Authorization: "Bearer test-admin-key" } + }); + const dashboard = await dashboardResponse.json(); + assert.equal(dashboardResponse.status, 200); + assert.deepEqual(dashboard.summary, { + total: 1, + opened: 0, + responded: 1, + attending: 2, + notComing: 0 + }); + + const changed = await patchRsvp({ answer: "alone", companionName: "Ignored Guest" }); + assert.equal(changed.response.status, 200); + assert.equal(changed.body.invite.answer, "alone"); + assert.equal(changed.body.invite.companionName, ""); + + const missingInvite = await patchRsvp({ answer: "not_coming" }, true, "missing-token"); + assert.equal(missingInvite.response.status, 404); +});