Allow admins to manage guest RSVPs
Publish and deploy birthday invitation / Check JavaScript (push) Successful in 4s
Publish and deploy birthday invitation / Build and push (push) Successful in 1m5s
Publish and deploy birthday invitation / Deploy to Kubernetes (push) Successful in 1m12s

This commit is contained in:
2026-10-14 21:19:04 +02:00
parent fe666111f8
commit bfac12a5bb
7 changed files with 260 additions and 17 deletions
+4
View File
@@ -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
+2
View File
@@ -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
+11 -2
View File
@@ -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) {
+32
View File
@@ -89,6 +89,7 @@
<th>Zuletzt geöffnet</th>
<th>Zuletzt gespeichert</th>
<th>Link</th>
<th>Aktion</th>
</tr>
</thead>
<tbody id="response-body"></tbody>
@@ -97,6 +98,37 @@
</div>
</section>
</div>
<dialog id="response-dialog" class="response-dialog">
<form id="response-editor-form">
<div class="dialog-heading">
<div>
<p class="eyebrow">Antwort bearbeiten</p>
<h2 id="response-dialog-title">Gast</h2>
</div>
<button id="response-dialog-close" class="dialog-close" type="button" aria-label="Dialog schließen">×</button>
</div>
<label for="admin-answer">Antwort</label>
<select id="admin-answer" required>
<option value="" disabled>Bitte auswählen</option>
<option value="not_coming">Kommt nicht</option>
<option value="alone">Kommt alleine</option>
<option value="with_someone">Kommt mit Begleitung</option>
</select>
<div id="admin-companion-wrap" hidden>
<label for="admin-companion-name">Name der Begleitung</label>
<input id="admin-companion-name" type="text" maxlength="100" autocomplete="off">
</div>
<p id="response-editor-error" class="form-error" role="alert" hidden></p>
<div class="dialog-actions">
<button id="response-dialog-cancel" class="small secondary" type="button">Abbrechen</button>
<button id="response-save-button" class="small" type="submit">Antwort speichern</button>
</div>
</form>
</dialog>
</main>
</body>
</html>
+87
View File
@@ -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 {
+46 -15
View File
@@ -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 };
+78
View File
@@ -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);
});