Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,12 @@ NEXTAUTH_SECRET=
CUSTOMERIO_SITE_ID=
CUSTOMERIO_API_KEY=
CUSTOMERIO_REGION=

# Build the Vote sign-ups → the Google Sheet shared with CJPAC, written with
# the Sheets API as a service account (src/lib/google-sheet.ts). Share the
# Sheet with the service account's email as an Editor. The private key is the
# JSON key's private_key; literal \n escapes are fine. Required in production
# (sign-ups fail without it); leave unset locally to skip the Sheet.
GOOGLE_SERVICE_ACCOUNT_EMAIL=
GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY=
GOOGLE_SHEET_ID=
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"seed:bills-admin": "tsx scripts/seed-bills-admin.ts",
"eval:bills": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env src/app/bills/evals/run.ts",
"test:feeds": "tsx --test src/lib/feeds.test.ts",
"test:sheet": "tsx --test src/lib/google-sheet.test.ts",
"test:polls": "NODE_OPTIONS=--conditions=import tsx --test src/lib/charts/inline-chart.test.ts src/lib/polls/downloads.test.ts src/lib/polls/crosstabs.test.ts"
},
"dependencies": {
Expand Down
53 changes: 53 additions & 0 deletions src/app/api/volunteer/details/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";

import {
AVAILABILITY_OPTIONS,
EMAIL_COOKIE,
MAYORAL_OPTIONS,
SHEET_COLUMNS,
SHEET_TAB,
} from "@/app/toronto/volunteer/build-the-vote";
import { sheetTimestamp, upsertSheetRow } from "@/lib/google-sheet";

// Build the Vote step 2: optional details, added to the volunteer's row in the
// Sheet shared with CJPAC. The row is found by the email step 1's response put
// in an httpOnly cookie, so the email is never in a URL or the page. These
// answers (including a mayoral preference) go to the Sheet only, not to
// Customer.io.

export async function POST(req: NextRequest) {
try {
const email = req.cookies.get(EMAIL_COOKIE)?.value;
if (!email) {
return NextResponse.json({ error: "expired" }, { status: 401 });
}

const body = await req.json();
const availability = Array.isArray(body.availability)
? AVAILABILITY_OPTIONS.filter((o) => body.availability.includes(o))
: [];
const mayoral = MAYORAL_OPTIONS.find((o) => o === body.mayoral) ?? "";
const issues =
typeof body.issues === "string" ? body.issues.trim().slice(0, 2000) : "";

await upsertSheetRow({
tab: SHEET_TAB,
columns: SHEET_COLUMNS,
email,
values: {
Availability: availability.join(", "),
"Mayoral candidate": mayoral,
Issues: issues,
"Details added at": sheetTimestamp(),
},
});

return NextResponse.json({ success: true });
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 },
);
}
}
128 changes: 97 additions & 31 deletions src/app/api/volunteer/route.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,115 @@
import { NextRequest, NextResponse } from "next/server";

import {
DETAILS_API_PATH,
EMAIL_COOKIE,
PARTNER_SOURCE,
SHEET_COLUMNS,
SHEET_TAB,
} from "@/app/toronto/volunteer/build-the-vote";
import { identifyToCustomerio } from "@/lib/customerio";
import { normalizePostalCode } from "@/lib/elections/postal-code";
import { sheetTimestamp, upsertSheetRow } from "@/lib/google-sheet";

// Build the Vote sign-ups, identified to Customer.io. The sign-up itself goes
// to CJPAC's Formstack; this copies who signed up into our own Customer.io so
// we can follow up. Deliberately not York Factory: these aren't newsletter
// subscribers created by our own form.
// Build the Vote step 1. Writes the sign-up to the Google Sheet shared with
// CJPAC, which is how CJPAC receives it, then identifies the volunteer to
// Customer.io for our own follow-up. Deliberately not York Factory: these
// aren't newsletter subscribers created by our newsletter form.
//
// The Sheet write must succeed, or the volunteer is told to try again: a
// sign-up CJPAC never sees is lost. Customer.io is best-effort. The phone
// number goes to the Sheet only; CJPAC calls, we don't.

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, first_name, last_name, city, postal_code } = body;

if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// A field people never see; bots that fill in every input do. Answer as
// if it worked so they don't learn to skip it.
if (body.website) return NextResponse.json({ success: true });

const str = (v: unknown, max: number) =>
typeof v === "string" ? v.trim().slice(0, max) : "";

const email = str(body.email, 254).toLowerCase();
const firstName = str(body.first_name, 50);
const lastName = str(body.last_name, 50);
const phone = normalizePhone(body.phone);
const city = str(body.city, 100);
const postalCode = normalizePostalCode(body.postal_code);

const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email) || email.length > 254) {
return NextResponse.json(
{ error: "Invalid email format" },
{ status: 400 },
);
const checks: [boolean, string][] = [
[Boolean(firstName && lastName), "Please enter your first and last name"],
[EMAIL_PATTERN.test(email), "Please enter a valid email address"],
[Boolean(phone), "Please enter a 10-digit phone number"],
[Boolean(city), "Please enter your city"],
[Boolean(postalCode), "Please enter a valid postal code (e.g. M5V 1A1)"],
];
const failed = checks.find(([ok]) => !ok);
if (failed) {
return NextResponse.json({ error: failed[1] }, { status: 400 });
}

const str = (v: unknown, max: number) =>
typeof v === "string" ? v.trim().slice(0, max) : undefined;
const firstName = str(first_name, 100);
const lastName = str(last_name, 100);

const identified = await identifyToCustomerio(email.trim().toLowerCase(), {
first_name: firstName,
last_name: lastName,
name: [firstName, lastName].filter(Boolean).join(" "),
city: str(city, 100),
postal_code: normalizePostalCode(postal_code),
toronto_2026_build_the_vote_volunteer: true,
// A Unix timestamp, so date-based segments can use it.
toronto_2026_build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
await upsertSheetRow({
tab: SHEET_TAB,
columns: SHEET_COLUMNS,
email,
values: {
"Signed up at": sheetTimestamp(),
"First name": firstName,
"Last name": lastName,
Phone: phone,
City: city,
"Postal code": postalCode,
Source: str(body.utm_source, 100),
Medium: str(body.utm_medium, 100),
Campaign: str(body.utm_campaign, 100),
Partner: PARTNER_SOURCE,
},
});

// `identified` is false when Customer.io isn't configured (local dev).
return NextResponse.json({ success: true, identified });
try {
await identifyToCustomerio(email, {
first_name: firstName,
last_name: lastName,
name: `${firstName} ${lastName}`,
city,
postal_code: postalCode,
toronto_2026_build_the_vote_volunteer: true,
// A Unix timestamp, so date-based segments can use it.
toronto_2026_build_the_vote_signed_up_at: Math.floor(Date.now() / 1000),
});
} catch (err) {
console.error(err);
}

// Lets step 2 add to this row without the email in a URL or the page.
const response = NextResponse.json({ success: true });
response.cookies.set(EMAIL_COOKIE, email, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: DETAILS_API_PATH,
maxAge: 60 * 60 * 2,
});
return response;
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
console.error(err);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 },
);
}
}

/** "416-555-0123", "(416) 555 0123", "+1 416…" → "(416) 555-0123". North
* American numbers only; anything else returns "". */
function normalizePhone(raw: unknown): string {
if (typeof raw !== "string") return "";
let digits = raw.replace(/\D/g, "");
if (digits.length === 11 && digits.startsWith("1")) digits = digits.slice(1);
if (digits.length !== 10) return "";
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
2 changes: 0 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@
/* Bills sub-app theme — scoped under .bills-scope (see src/app/bills/bills.css). */
@import "./bills/bills.css";

/* CJPAC's Formstack forms on the Build the Vote pages. */
@import "./toronto/volunteer/formstack.css";

html {
scroll-behavior: auto;
Expand Down
Loading
Loading