183 lines
6.8 KiB
TypeScript
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);
|
|
}
|