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

+1
View File
@@ -6,6 +6,7 @@ import { USE_CASE_DETAIL_SLUGS } from "./useCaseSyntheticPost";
const STATIC_PUBLIC_PATHS = [
"/",
"/about",
"/workshops",
"/learn",
"/templates",
"/blog",
+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;
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Shared workshop-signup constants and routing. Safe for the form and the server.
* Member count never maps to a price.
*/
export const WORKSHOP_SIGNUP_ANCHOR_ID = "workshop-signup";
export const WORKSHOP_HONEYPOT_FIELD = "company_website";
export const WORKSHOP_STARTED_AT_FIELD = "startedAt";
/** Ignore submits that arrive faster than a person can fill the form. */
export const WORKSHOP_MIN_FILL_MS = 4_000;
/** Proposed retention. Inquiries that do not lead to a workshop are deleted after this. */
export const WORKSHOP_INQUIRY_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
export const WORKSHOP_CHALLENGE_IDS = [
"members-rarely-vote",
"preparing-the-board",
"board-and-management",
"committees-unsure",
"disagreement",
"big-change",
"new-directors",
"co-op-principles",
"something-else",
] as const;
export type WorkshopChallengeId = (typeof WORKSHOP_CHALLENGE_IDS)[number];
export const WORKSHOP_ORGANIZATION_KINDS = [
"credit-union",
"mutual",
"consumer-coop",
"utility-coop",
"agricultural-coop",
"housing-coop",
"worker-coop",
"league",
"volunteer",
"something-else",
] as const;
export type WorkshopOrganizationKind = (typeof WORKSHOP_ORGANIZATION_KINDS)[number];
export const WORKSHOP_MEMBER_COUNTS = [
"under-1000",
"1000-10000",
"10000-100000",
"100000-1000000",
"over-1000000",
"not-sure",
] as const;
export type WorkshopMemberCount = (typeof WORKSHOP_MEMBER_COUNTS)[number];
export const WORKSHOP_ROLES = [
"board",
"executive",
"staff",
"member",
"something-else",
] as const;
export type WorkshopRole = (typeof WORKSHOP_ROLES)[number];
const LARGE_MEMBER_COUNTS = new Set<WorkshopMemberCount>([
"1000-10000",
"10000-100000",
"100000-1000000",
"over-1000000",
]);
export type WorkshopFieldErrorCode = "required" | "invalid" | "tooLong";
export type WorkshopFieldErrors = Partial<
Record<
| "challenges"
| "challengeOther"
| "unspokenQuestion"
| "organizationName"
| "organizationKind"
| "memberCount"
| "contactName"
| "email"
| "role",
WorkshopFieldErrorCode
>
>;
export interface WorkshopFormValues {
challenges: string[];
challengeOther: string;
unspokenQuestion: string;
organizationName: string;
organizationKind: string;
memberCount: string;
contactName: string;
email: string;
role: string;
sourcePage: string;
sourceUrl: string;
}
export const EMPTY_WORKSHOP_FORM_VALUES: WorkshopFormValues = {
challenges: [],
challengeOther: "",
unspokenQuestion: "",
organizationName: "",
organizationKind: "",
memberCount: "",
contactName: "",
email: "",
role: "",
sourcePage: "/workshops",
sourceUrl: "",
};
export interface WorkshopRouteSuggestion {
/** True when the organization has 1,000 members or more. Never a price. */
largeOrganization: boolean;
/** Inbox note. Names the next conversation, never a fee. */
note: string;
}
export function isLargeMemberCount(memberCount: string): boolean {
return LARGE_MEMBER_COUNTS.has(memberCount as WorkshopMemberCount);
}
/**
* Inbox routing. Volunteer-run groups go to the kit. Organizations of 1,000
* members or more are flagged large and go to the facilitator lead. Leagues
* go to a partnership call. Everyone else gets the one-page brief.
*/
export function suggestWorkshopRoute(input: {
organizationKind: string;
memberCount: string;
}): WorkshopRouteSuggestion {
if (input.organizationKind === "volunteer") {
return {
largeOrganization: false,
note: "Volunteer-run group: send the kit and office hours.",
};
}
if (isLargeMemberCount(input.memberCount)) {
return {
largeOrganization: true,
note: "Large organization: call with the facilitator lead.",
};
}
if (input.organizationKind === "league") {
return {
largeOrganization: false,
note: "League, association or co-op developer: partnership call.",
};
}
return {
largeOrganization: false,
note: "Send the one-page brief, then offer a call.",
};
}
+90
View File
@@ -0,0 +1,90 @@
import type { WorkshopFormValues } from "./workshopSignup";
export interface WorkshopInquiryMailInput {
values: WorkshopFormValues;
routeNote: string;
largeOrganization: boolean;
requestId: string;
schedulingUrl: string;
}
function line(label: string, value: string): string {
const trimmed = value.trim();
return trimmed.length > 0 ? `${label}: ${trimmed}` : `${label}:`;
}
/** Inbox note for the team. Includes the answers and a route. No price. */
export function buildWorkshopInboxMail(input: WorkshopInquiryMailInput): {
subject: string;
text: string;
html: string;
} {
const { values } = input;
const challenges = values.challenges.join(", ");
const text = [
`Request ID: ${input.requestId}`,
line("Organization", values.organizationName),
line("Kind", values.organizationKind),
line("Members", values.memberCount),
`Large organization: ${input.largeOrganization ? "yes" : "no"}`,
line("Route", input.routeNote),
line("Name", values.contactName),
line("Email", values.email),
line("Role", values.role),
line("Challenges", challenges),
line("Something else", values.challengeOther),
line("Unspoken question", values.unspokenQuestion),
line("Page", values.sourcePage),
line("Link", values.sourceUrl),
].join("\n");
const html = text
.split("\n")
.map((row) => `<p>${escapeHtml(row)}</p>`)
.join("");
return {
subject: `Workshop inquiry from ${values.organizationName}`,
text,
html,
};
}
/** Confirmation to the person who wrote. Promises two working days and names no price. */
export function buildWorkshopConfirmationMail(input: {
largeOrganization: boolean;
schedulingUrl: string;
}): { subject: string; text: string; html: string } {
const subject = "We received your workshop note";
const intro =
"Thank you. A facilitator will read this closely and write back within two working days.";
const next = input.largeOrganization
? "Organizations of this size start with a call."
: "The kit has the four questions and the thirteen templates if you want a head start.";
const schedule =
input.largeOrganization && input.schedulingUrl.trim().length > 0
? `Choose a time: ${input.schedulingUrl.trim()}`
: "";
const text = [intro, next, schedule].filter((part) => part.length > 0).join("\n\n");
const html =
`<div style="background:#141414;padding:24px 32px;">` +
`<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;letter-spacing:0.08em;color:#ffffff;">COMMUNITYRULE</p>` +
`</div>` +
`<div style="background:#ffffff;padding:32px;font-family:Inter,Arial,sans-serif;color:#141414;">` +
`<p>${escapeHtml(intro)}</p>` +
`<p>${escapeHtml(next)}</p>` +
(schedule
? `<p><a href="${escapeHtml(input.schedulingUrl.trim())}">Choose a time</a></p>`
: "") +
`</div>`;
return { subject, text, html };
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}