/** * Magic link routes — mounted at top level to bypass space auth middleware. * GET /respond/:token — verify HMAC, render poll or RSVP page * POST /respond/:token — submit response, re-render with confirmation */ import { Hono } from "hono"; import { verifyMagicToken } from "./token"; import { renderPollPage, renderRsvpPage, renderError } from "./render"; import { syncServer } from "../sync-instance"; import { choicesDocId } from "../../modules/rchoices/schemas"; import { calendarDocId } from "../../modules/rcal/schemas"; import type { ChoicesDoc } from "../../modules/rchoices/schemas"; import type { CalendarDoc } from "../../modules/rcal/schemas"; import type { EventAttendee } from "../../modules/rcal/schemas"; export const magicLinkRoutes = new Hono(); // ── GET /respond/:token ── magicLinkRoutes.get("/:token", async (c) => { const token = c.req.param("token"); let verified; try { verified = await verifyMagicToken(token); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; const title = message === "Token expired" ? "Link Expired" : "Invalid Link"; const body = message === "Token expired" ? "This response link has expired. Request a new invitation." : "This link is not valid. It may have been corrupted."; return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400); } try { if (verified.type === "poll") { return c.html(renderPollForToken(verified, token)); } else { return c.html(renderRsvpForToken(verified, token)); } } catch (err) { console.error("[MagicLink] Render error:", err); return c.html(renderError("Error", "Could not load the response page."), 500); } }); // ── POST /respond/:token ── magicLinkRoutes.post("/:token", async (c) => { const token = c.req.param("token"); let verified; try { verified = await verifyMagicToken(token); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; const title = message === "Token expired" ? "Link Expired" : "Invalid Link"; const body = message === "Token expired" ? "This response link has expired. Request a new invitation." : "This link is not valid. It may have been corrupted."; return c.html(renderError(title, body), title === "Link Expired" ? 410 : 400); } try { const formData = await c.req.parseBody(); if (verified.type === "poll") { const choice = formData.choice as string; if (!choice) return c.html(renderError("Error", "No choice submitted."), 400); const docId = choicesDocId(verified.space); const doc = syncServer.getDoc(docId); if (!doc) return c.html(renderError("Not Found", "Poll data not found."), 404); const session = doc.sessions[verified.targetId]; if (!session) return c.html(renderError("Not Found", "Poll session not found."), 404); // Validate choice is a valid option if (!session.options.some((o) => o.id === choice)) { return c.html(renderError("Error", "Invalid option selected."), 400); } // Write vote keyed by sessionId:magic:tokenHash (dedup by token) const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`; syncServer.changeDoc(docId, `magic link vote by ${verified.respondentName}`, (d) => { d.votes[voteKey] = { participantDid: `magic:${verified.tokenHash}`, choices: { [choice]: 1 }, updatedAt: Date.now(), }; }); return c.html(renderPollForToken(verified, token, choice)); } else { // RSVP const status = formData.status as string; if (!status || !["yes", "no", "maybe"].includes(status)) { return c.html(renderError("Error", "Invalid RSVP status."), 400); } const docId = calendarDocId(verified.space); const doc = syncServer.getDoc(docId); if (!doc) return c.html(renderError("Not Found", "Calendar data not found."), 404); const event = doc.events[verified.targetId]; if (!event) return c.html(renderError("Not Found", "Event not found."), 404); syncServer.changeDoc(docId, `magic link RSVP by ${verified.respondentName}`, (d) => { const ev = d.events[verified.targetId]; const attendees = (ev.attendees || []) as EventAttendee[]; // Find existing response by tokenHash (update, not duplicate) const existingIdx = attendees.findIndex((a) => a.tokenHash === verified.tokenHash); const attendee: EventAttendee = { name: verified.respondentName, status: status as "yes" | "no" | "maybe", respondedAt: Date.now(), source: "magic-link", tokenHash: verified.tokenHash, }; if (existingIdx >= 0) { attendees[existingIdx] = attendee; } else { attendees.push(attendee); } ev.attendees = attendees; ev.attendeeCount = attendees.filter((a) => a.status === "yes").length; }); return c.html(renderRsvpForToken(verified, token, status)); } } catch (err) { console.error("[MagicLink] Submit error:", err); return c.html(renderError("Error", "Could not submit your response. Please try again."), 500); } }); // ── Helpers ── function renderPollForToken( verified: { space: string; targetId: string; respondentName: string; tokenHash: string }, token: string, justVoted?: string, ): string { const docId = choicesDocId(verified.space); const doc = syncServer.getDoc(docId); if (!doc) return renderError("Not Found", "Poll data not found."); const session = doc.sessions[verified.targetId]; if (!session) return renderError("Not Found", "Poll session not found."); // Check for existing vote by this token const voteKey = `${verified.targetId}:magic:${verified.tokenHash}`; const existingVote = doc.votes[voteKey]; const existingChoice = existingVote ? Object.entries(existingVote.choices).find(([, v]) => v === 1)?.[0] : undefined; return renderPollPage(session, token, verified.respondentName, existingChoice, justVoted); } function renderRsvpForToken( verified: { space: string; targetId: string; respondentName: string; tokenHash: string }, token: string, justResponded?: string, ): string { const docId = calendarDocId(verified.space); const doc = syncServer.getDoc(docId); if (!doc) return renderError("Not Found", "Calendar data not found."); const event = doc.events[verified.targetId]; if (!event) return renderError("Not Found", "Event not found."); // Check for existing RSVP by this token const attendees = (event.attendees || []) as EventAttendee[]; const existing = attendees.find((a) => a.tokenHash === verified.tokenHash); const currentStatus = existing?.status; return renderRsvpPage(event, token, verified.respondentName, currentStatus, justResponded); }