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

+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;
}