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>
232 lines
6.6 KiB
TypeScript
232 lines
6.6 KiB
TypeScript
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;
|
|
}
|