Allow admins to manage guest RSVPs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user