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