rspace-online/server/magic-link/routes.ts

183 lines
6.8 KiB
TypeScript

/**
* 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<ChoicesDoc>(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<ChoicesDoc>(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<CalendarDoc>(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<CalendarDoc>(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<ChoicesDoc>(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<CalendarDoc>(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);
}