Add the Workshops page and keep each sign-up on the site.

The form is built once to the settled field set, and a valid send is saved before anyone is emailed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-11-03 19:56:49 -07:00
1 parent 974a5ace4c
commit c0ba3a3798
50 files changed
+2773 -1

No files matched your search

+34
View File
@@ -1,5 +1,10 @@
import nodemailer from "nodemailer";
import { logger } from "../logger";
import {
buildWorkshopConfirmationMail,
buildWorkshopInboxMail,
type WorkshopInquiryMailInput,
} from "../workshopSignupMail";
import { getSmtpUrl } from "./env";
function escapeHtml(value: string): string {
@@ -134,6 +139,35 @@ export async function sendOrganizerInquiryNotification(params: {
});
}
export async function sendWorkshopInquiryNotification(
input: WorkshopInquiryMailInput & { to: string },
): Promise<void> {
const { subject, text, html } = buildWorkshopInboxMail(input);
await sendHtmlMail({
to: input.to,
replyTo: input.values.email,
subject,
text,
html,
devLog: `[dev] Workshop inquiry ${input.requestId} from ${input.values.email} to ${input.to}:\n${text}`,
});
}
export async function sendWorkshopInquiryConfirmation(input: {
to: string;
largeOrganization: boolean;
schedulingUrl: string;
}): Promise<void> {
const { subject, text, html } = buildWorkshopConfirmationMail(input);
await sendHtmlMail({
to: input.to,
subject,
text,
html,
devLog: `[dev] Workshop confirmation for ${input.to} (large=${input.largeOrganization})`,
});
}
export async function sendEmailChangeEmail(
to: string,
verifyUrl: string,
@@ -0,0 +1,115 @@
import {
WORKSHOP_CHALLENGE_IDS,
WORKSHOP_HONEYPOT_FIELD,
WORKSHOP_MEMBER_COUNTS,
WORKSHOP_ORGANIZATION_KINDS,
WORKSHOP_ROLES,
WORKSHOP_STARTED_AT_FIELD,
type WorkshopFieldErrors,
type WorkshopFormValues,
} from "../../workshopSignup";
const CHALLENGE_SET = new Set<string>(WORKSHOP_CHALLENGE_IDS);
const KIND_SET = new Set<string>(WORKSHOP_ORGANIZATION_KINDS);
const MEMBER_SET = new Set<string>(WORKSHOP_MEMBER_COUNTS);
const ROLE_SET = new Set<string>(WORKSHOP_ROLES);
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export interface ParsedWorkshopSignup {
values: WorkshopFormValues;
fieldErrors: WorkshopFieldErrors;
honeypot: string;
startedAt: number | null;
}
function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function asStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === "string");
}
if (typeof value === "string" && value.trim().length > 0) {
return [value];
}
return [];
}
export function parseWorkshopSignupPayload(raw: unknown): ParsedWorkshopSignup {
const record =
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const challenges = [
...new Set(asStringList(record.challenges).map((item) => item.trim())),
].filter((item) => CHALLENGE_SET.has(item));
const values: WorkshopFormValues = {
challenges,
challengeOther: asString(record.challengeOther).trim().slice(0, 200),
unspokenQuestion: asString(record.unspokenQuestion).trim(),
organizationName: asString(record.organizationName).trim(),
organizationKind: asString(record.organizationKind).trim(),
memberCount: asString(record.memberCount).trim(),
contactName: asString(record.contactName).trim(),
email: asString(record.email).trim().toLowerCase(),
role: asString(record.role).trim(),
sourcePage: asString(record.sourcePage).trim() || "/workshops",
sourceUrl: asString(record.sourceUrl).trim().slice(0, 2000),
};
const fieldErrors: WorkshopFieldErrors = {};
if (values.challenges.length === 0) fieldErrors.challenges = "required";
if (
values.challenges.includes("something-else") &&
values.challengeOther.length === 0
) {
fieldErrors.challengeOther = "required";
}
if (values.unspokenQuestion.length > 1000) fieldErrors.unspokenQuestion = "tooLong";
if (values.organizationName.length === 0) fieldErrors.organizationName = "required";
else if (values.organizationName.length > 150) fieldErrors.organizationName = "tooLong";
if (!KIND_SET.has(values.organizationKind)) fieldErrors.organizationKind = "required";
if (!MEMBER_SET.has(values.memberCount)) fieldErrors.memberCount = "required";
if (values.contactName.length === 0) fieldErrors.contactName = "required";
else if (values.contactName.length > 100) fieldErrors.contactName = "tooLong";
if (values.email.length === 0 || !EMAIL_PATTERN.test(values.email) || values.email.length > 254) {
fieldErrors.email = values.email.length === 0 ? "required" : "invalid";
}
if (values.role.length > 0 && !ROLE_SET.has(values.role)) fieldErrors.role = "invalid";
const startedRaw = record[WORKSHOP_STARTED_AT_FIELD];
const startedAt =
typeof startedRaw === "number"
? startedRaw
: typeof startedRaw === "string" && startedRaw.trim().length > 0
? Number(startedRaw)
: null;
return {
values,
fieldErrors,
honeypot: asString(record[WORKSHOP_HONEYPOT_FIELD]).trim(),
startedAt: startedAt !== null && Number.isFinite(startedAt) ? startedAt : null,
};
}
export function workshopSignupFromFormData(formData: FormData): ParsedWorkshopSignup {
return parseWorkshopSignupPayload({
challenges: formData.getAll("challenges"),
challengeOther: formData.get("challengeOther"),
unspokenQuestion: formData.get("unspokenQuestion"),
organizationName: formData.get("organizationName"),
organizationKind: formData.get("organizationKind"),
memberCount: formData.get("memberCount"),
contactName: formData.get("contactName"),
email: formData.get("email"),
role: formData.get("role"),
sourcePage: formData.get("sourcePage"),
sourceUrl: formData.get("sourceUrl"),
[WORKSHOP_STARTED_AT_FIELD]: formData.get(WORKSHOP_STARTED_AT_FIELD),
[WORKSHOP_HONEYPOT_FIELD]: formData.get(WORKSHOP_HONEYPOT_FIELD),
});
}
+39
View File
@@ -0,0 +1,39 @@
import { cookies } from "next/headers";
import { sha256Hex } from "./hash";
export const WORKSHOP_INQUIRY_ADMIN_COOKIE = "workshop_inquiry_admin";
export function workshopInquiryAdminToken(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_ADMIN_TOKEN?.trim();
return raw && raw.length > 0 ? raw : null;
}
export function workshopInquiryPurgeToken(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_PURGE_TOKEN?.trim();
return raw && raw.length > 0 ? raw : null;
}
function cookieValue(token: string): string {
return sha256Hex(`workshop-inquiry-admin:${token}`);
}
export async function isWorkshopInquiryAdmin(): Promise<boolean> {
const token = workshopInquiryAdminToken();
if (!token) return false;
const store = await cookies();
return store.get(WORKSHOP_INQUIRY_ADMIN_COOKIE)?.value === cookieValue(token);
}
export async function grantWorkshopInquiryAdmin(password: string): Promise<boolean> {
const token = workshopInquiryAdminToken();
if (!token || password !== token) return false;
const store = await cookies();
store.set(WORKSHOP_INQUIRY_ADMIN_COOKIE, cookieValue(token), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 12,
});
return true;
}
+231
View File
@@ -0,0 +1,231 @@
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
import {
sendWorkshopInquiryConfirmation,
sendWorkshopInquiryNotification,
} from "./mail";
import { rateLimitKey } from "./rateLimit";
import { logger } from "../logger";
import {
WORKSHOP_INQUIRY_RETENTION_MS,
WORKSHOP_MIN_FILL_MS,
suggestWorkshopRoute,
type WorkshopFormValues,
} from "../workshopSignup";
import {
parseWorkshopSignupPayload,
type ParsedWorkshopSignup,
} from "./validation/workshopSignupSchemas";
const EMAIL_MIN_INTERVAL_MS = 60 * 1000;
export type WorkshopSignupStatus =
| "sent"
| "invalid"
| "failed"
| "rate_limited"
| "unavailable";
export interface WorkshopSignupResult {
status: WorkshopSignupStatus;
largeOrganization: boolean;
values: WorkshopFormValues;
fieldErrors: ParsedWorkshopSignup["fieldErrors"];
}
function inboxAddress(): string | null {
const raw = process.env.WORKSHOP_INQUIRY_TO?.trim();
return raw && raw.length > 0 ? raw : null;
}
function schedulingUrl(): string {
return process.env.NEXT_PUBLIC_WORKSHOP_SCHEDULING_URL?.trim() ?? "";
}
function resultFromParse(
parsed: ParsedWorkshopSignup,
status: WorkshopSignupStatus,
largeOrganization = false,
): WorkshopSignupResult {
return {
status,
largeOrganization,
values: parsed.values,
fieldErrors: parsed.fieldErrors,
};
}
/**
* Save a valid inquiry, then email the inbox and the person who wrote.
* A filled honeypot or a too-fast submit shows the confirmation and saves nothing.
* A failed save sends nothing. A failed email keeps the row for a later retry.
*/
export async function submitWorkshopSignup(
raw: unknown,
requestId: string,
): Promise<WorkshopSignupResult> {
const parsed = parseWorkshopSignupPayload(raw);
if (Object.keys(parsed.fieldErrors).length > 0) {
return resultFromParse(parsed, "invalid");
}
const tooFast =
parsed.startedAt === null ||
Date.now() - parsed.startedAt < WORKSHOP_MIN_FILL_MS;
if (parsed.honeypot.length > 0 || tooFast) {
return resultFromParse(parsed, "sent", false);
}
const limited = rateLimitKey(
`workshop-signup-email:${parsed.values.email}`,
EMAIL_MIN_INTERVAL_MS,
);
if (limited.ok === false) {
return resultFromParse(parsed, "rate_limited");
}
if (!isDatabaseConfigured()) {
return resultFromParse(parsed, "unavailable");
}
const route = suggestWorkshopRoute(parsed.values);
let inquiryId: string;
try {
const row = await prisma.workshopInquiry.create({
data: {
challenges: parsed.values.challenges,
challengeOther: parsed.values.challengeOther || null,
unspokenQuestion: parsed.values.unspokenQuestion || null,
organizationName: parsed.values.organizationName,
organizationKind: parsed.values.organizationKind,
memberCount: parsed.values.memberCount,
contactName: parsed.values.contactName,
email: parsed.values.email,
role: parsed.values.role || null,
sourcePage: parsed.values.sourcePage,
sourceUrl: parsed.values.sourceUrl || null,
routeSuggestion: route.note,
largeOrganization: route.largeOrganization,
},
});
inquiryId = row.id;
} catch (err) {
logger.error("workshop-signup.save", err);
return resultFromParse(parsed, "failed");
}
await deliverWorkshopInquiryMail(inquiryId, parsed.values, route, requestId, {
inbox: true,
confirmation: true,
});
return resultFromParse(parsed, "sent", route.largeOrganization);
}
async function deliverWorkshopInquiryMail(
inquiryId: string,
values: WorkshopFormValues,
route: { note: string; largeOrganization: boolean },
requestId: string,
which: { inbox: boolean; confirmation: boolean },
): Promise<void> {
const to = inboxAddress();
if (which.inbox && to) {
try {
await sendWorkshopInquiryNotification({
to,
values,
routeNote: route.note,
largeOrganization: route.largeOrganization,
requestId,
schedulingUrl: schedulingUrl(),
});
await prisma.workshopInquiry.update({
where: { id: inquiryId },
data: { inboxNotifiedAt: new Date() },
});
} catch (err) {
logger.error("workshop-signup.inbox-mail", err);
}
}
if (which.confirmation) {
try {
await sendWorkshopInquiryConfirmation({
to: values.email,
largeOrganization: route.largeOrganization,
schedulingUrl: schedulingUrl(),
});
await prisma.workshopInquiry.update({
where: { id: inquiryId },
data: { confirmationSentAt: new Date() },
});
} catch (err) {
logger.error("workshop-signup.confirmation-mail", err);
}
}
}
/** Retry inbox and confirmation mail for rows that were saved but not sent. */
export async function retryPendingWorkshopInquiryMail(): Promise<number> {
if (!isDatabaseConfigured()) return 0;
const pending = await prisma.workshopInquiry.findMany({
where: {
OR: [{ inboxNotifiedAt: null }, { confirmationSentAt: null }],
},
orderBy: { createdAt: "asc" },
take: 50,
});
let sent = 0;
for (const row of pending) {
const values: WorkshopFormValues = {
challenges: row.challenges,
challengeOther: row.challengeOther ?? "",
unspokenQuestion: row.unspokenQuestion ?? "",
organizationName: row.organizationName,
organizationKind: row.organizationKind,
memberCount: row.memberCount,
contactName: row.contactName,
email: row.email,
role: row.role ?? "",
sourcePage: row.sourcePage,
sourceUrl: row.sourceUrl ?? "",
};
const beforeInbox = row.inboxNotifiedAt;
const beforeConfirmation = row.confirmationSentAt;
await deliverWorkshopInquiryMail(
row.id,
values,
{ note: row.routeSuggestion, largeOrganization: row.largeOrganization },
`retry-${row.id}`,
{
inbox: row.inboxNotifiedAt === null,
confirmation: row.confirmationSentAt === null,
},
);
const after = await prisma.workshopInquiry.findUnique({ where: { id: row.id } });
if (
after &&
((beforeInbox === null && after.inboxNotifiedAt !== null) ||
(beforeConfirmation === null && after.confirmationSentAt !== null))
) {
sent += 1;
}
}
return sent;
}
/** Delete inquiries older than the retention period. */
export async function purgeExpiredWorkshopInquiries(now = new Date()): Promise<number> {
if (!isDatabaseConfigured()) return 0;
const cutoff = new Date(now.getTime() - WORKSHOP_INQUIRY_RETENTION_MS);
const result = await prisma.workshopInquiry.deleteMany({
where: { createdAt: { lt: cutoff } },
});
return result.count;
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import {
EMPTY_WORKSHOP_FORM_VALUES,
type WorkshopFieldErrors,
type WorkshopFormValues,
} from "../workshopSignup";
export const WORKSHOP_SIGNUP_DRAFT_COOKIE = "workshop_signup_draft";
export interface WorkshopSignupDraft {
values: WorkshopFormValues;
fieldErrors: WorkshopFieldErrors;
}
export function encodeWorkshopSignupDraft(draft: WorkshopSignupDraft): string {
return Buffer.from(JSON.stringify(draft), "utf8").toString("base64url");
}
export function decodeWorkshopSignupDraft(raw: string | undefined): WorkshopSignupDraft | null {
if (!raw) return null;
try {
const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Partial<WorkshopSignupDraft>;
if (!parsed.values || typeof parsed.values !== "object") return null;
return {
values: { ...EMPTY_WORKSHOP_FORM_VALUES, ...parsed.values },
fieldErrors: parsed.fieldErrors ?? {},
};
} catch {
return null;
}
}
export function attachWorkshopSignupDraft(
response: NextResponse,
draft: WorkshopSignupDraft,
): NextResponse {
response.cookies.set(WORKSHOP_SIGNUP_DRAFT_COOKIE, encodeWorkshopSignupDraft(draft), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/workshops",
maxAge: 60 * 15,
});
return response;
}