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,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { prisma } from "../../../../lib/server/db";
|
||||
import { dbUnavailable, notFound, unauthorized } from "../../../../lib/server/responses";
|
||||
import { isWorkshopInquiryAdmin } from "../../../../lib/server/workshopInquiryAdmin";
|
||||
|
||||
function csvCell(value: string): string {
|
||||
if (/[",\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
|
||||
return value;
|
||||
}
|
||||
|
||||
export const GET = apiRoute("workshop-inquiries.export", async (_request: NextRequest) => {
|
||||
if (!(await isWorkshopInquiryAdmin())) {
|
||||
return process.env.WORKSHOP_INQUIRY_ADMIN_TOKEN?.trim()
|
||||
? unauthorized()
|
||||
: notFound();
|
||||
}
|
||||
if (!isDatabaseConfigured()) return dbUnavailable();
|
||||
|
||||
const rows = await prisma.workshopInquiry.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const header = [
|
||||
"id",
|
||||
"createdAt",
|
||||
"organizationName",
|
||||
"organizationKind",
|
||||
"memberCount",
|
||||
"largeOrganization",
|
||||
"routeSuggestion",
|
||||
"contactName",
|
||||
"email",
|
||||
"role",
|
||||
"challenges",
|
||||
"challengeOther",
|
||||
"unspokenQuestion",
|
||||
"sourcePage",
|
||||
"sourceUrl",
|
||||
];
|
||||
|
||||
const lines = [header.join(",")];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
[
|
||||
row.id,
|
||||
row.createdAt.toISOString(),
|
||||
row.organizationName,
|
||||
row.organizationKind,
|
||||
row.memberCount,
|
||||
row.largeOrganization ? "yes" : "no",
|
||||
row.routeSuggestion,
|
||||
row.contactName,
|
||||
row.email,
|
||||
row.role ?? "",
|
||||
row.challenges.join("; "),
|
||||
row.challengeOther ?? "",
|
||||
row.unspokenQuestion ?? "",
|
||||
row.sourcePage,
|
||||
row.sourceUrl ?? "",
|
||||
]
|
||||
.map((cell) => csvCell(String(cell)))
|
||||
.join(","),
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(lines.join("\n"), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/csv; charset=utf-8",
|
||||
"content-disposition": "attachment; filename=\"workshop-inquiries.csv\"",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiRoute } from "../../../../lib/server/apiRoute";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isDatabaseConfigured } from "../../../../lib/server/env";
|
||||
import { dbUnavailable, notFound, unauthorized } from "../../../../lib/server/responses";
|
||||
import { workshopInquiryPurgeToken } from "../../../../lib/server/workshopInquiryAdmin";
|
||||
import {
|
||||
purgeExpiredWorkshopInquiries,
|
||||
retryPendingWorkshopInquiryMail,
|
||||
} from "../../../../lib/server/workshopSignup";
|
||||
|
||||
export const POST = apiRoute("workshop-inquiries.purge", async (request: NextRequest) => {
|
||||
const token = workshopInquiryPurgeToken();
|
||||
if (!token) return notFound();
|
||||
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (header !== `Bearer ${token}`) return unauthorized();
|
||||
if (!isDatabaseConfigured()) return dbUnavailable();
|
||||
|
||||
const retried = await retryPendingWorkshopInquiryMail();
|
||||
const deleted = await purgeExpiredWorkshopInquiries();
|
||||
|
||||
return NextResponse.json({ ok: true, retried, deleted });
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiRoute } from "../../../lib/server/apiRoute";
|
||||
import { submitWorkshopSignup } from "../../../lib/server/workshopSignup";
|
||||
import { attachWorkshopSignupDraft } from "../../../lib/server/workshopSignupDraft";
|
||||
import { readLimitedJson } from "../../../lib/server/validation/requestBody";
|
||||
import { workshopSignupFromFormData } from "../../../lib/server/validation/workshopSignupSchemas";
|
||||
|
||||
const SCOPE = "workshop-signup.submit";
|
||||
|
||||
function wantsHtmlForm(request: NextRequest): boolean {
|
||||
const type = request.headers.get("content-type") ?? "";
|
||||
return (
|
||||
type.includes("application/x-www-form-urlencoded") ||
|
||||
type.includes("multipart/form-data")
|
||||
);
|
||||
}
|
||||
|
||||
function redirectBack(
|
||||
request: NextRequest,
|
||||
status: string,
|
||||
largeOrganization: boolean,
|
||||
): NextResponse {
|
||||
const url = new URL("/workshops", request.url);
|
||||
url.searchParams.set("signup", status);
|
||||
if (status === "sent") {
|
||||
url.searchParams.set("band", largeOrganization ? "large" : "standard");
|
||||
}
|
||||
url.hash = "workshop-signup";
|
||||
return NextResponse.redirect(url, 303);
|
||||
}
|
||||
|
||||
export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { requestId }) => {
|
||||
const htmlForm = wantsHtmlForm(request);
|
||||
|
||||
let raw: unknown;
|
||||
if (htmlForm) {
|
||||
const formData = await request.formData();
|
||||
const parsed = workshopSignupFromFormData(formData);
|
||||
raw = {
|
||||
...parsed.values,
|
||||
company_website: parsed.honeypot,
|
||||
startedAt: parsed.startedAt,
|
||||
};
|
||||
} else {
|
||||
const parsedBody = await readLimitedJson(request);
|
||||
if (parsedBody.ok === false) return parsedBody.response;
|
||||
raw = parsedBody.value;
|
||||
}
|
||||
|
||||
const result = await submitWorkshopSignup(raw, requestId);
|
||||
|
||||
if (htmlForm) {
|
||||
const status =
|
||||
result.status === "unavailable" ? "failed" : result.status === "sent" ? "sent" : result.status;
|
||||
const response = redirectBack(request, status === "rate_limited" ? "failed" : status, result.largeOrganization);
|
||||
if (result.status !== "sent") {
|
||||
attachWorkshopSignupDraft(response, {
|
||||
values: result.values,
|
||||
fieldErrors: result.fieldErrors,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
if (result.status === "invalid") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
status: "invalid",
|
||||
fieldErrors: result.fieldErrors,
|
||||
values: result.values,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status === "rate_limited") {
|
||||
return NextResponse.json({ ok: false, status: "rate_limited" }, { status: 429 });
|
||||
}
|
||||
|
||||
if (result.status === "failed" || result.status === "unavailable") {
|
||||
return NextResponse.json({ ok: false, status: "failed" }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: "sent",
|
||||
largeOrganization: result.largeOrganization,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user