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:
1 parent
974a5ace4c
commit
c0ba3a3798
50 files changed
+2773
-1
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user