Skip to content

Build the Vote: our own sign-up forms, written to a Google Sheet for CJPAC - #112

Merged
xrendan merged 1 commit into
mainfrom
feat/volunteer-google-sheet
Oct 16, 2026
Merged

xrendan merged 1 commit into
mainfrom
feat/volunteer-google-sheet

Conversation

@xrendan

@xrendan xrendan commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Replaces CJPAC's Formstack embeds on /toronto/volunteer with our own two-step form. CJPAC now gets sign-ups through a Google Sheet that we write to with the Sheets API.

Flow

  1. Step 1 (/toronto/volunteer#signup): name, email, phone, city and postal code, posted to /api/volunteer. That route:
    • validates the fields: name required, a 10-digit North American phone (stored as (416) 555-0123), and a valid postal code
    • writes a row to the Sheet. This must succeed: if it fails, the volunteer sees an error and can retry, since a sign-up CJPAC never sees is lost
    • identifies the volunteer to Customer.io, best-effort. Same attributes as before; the phone number is not sent
    • sets an httpOnly btv_email cookie scoped to /api/volunteer/details
  2. Step 2 (optional details: availability, mayoral pick, issues): posted to /api/volunteer/details, which fills in the same row, found by the cookie. The email never appears in a URL or in the page. These answers go to the Sheet only, not Customer.io. If the cookie has expired, the volunteer still goes on to the thanks page, since their sign-up is already saved.
  3. Thanks page: unchanged.

A hidden honeypot field drops form-filling bots.

The Sheet

src/lib/google-sheet.ts calls the Sheets REST API as a service account. It signs the token with jose, which is already a dependency, so there are no new packages.

  • Rows are upserted by email: step 1 appends, step 2 fills in.
  • Values are written by header name. On an empty tab it writes the header row; columns can then be reordered in the Sheet, and any missing column is added at the end.
  • Everything is written RAW, so input is never treated as a formula.

Tab Sign-ups, with columns:

Signed up at · First name · Last name · Email · Phone · City · Postal code · Availability · Mayoral candidate · Issues · Details added at · Source · Medium · Campaign · Partner

Source, Medium and Campaign are the visitor's utm_* tags. Partner is always buildcanada.

⚠️ Before merging

Set these in the deployment environment:

  • GOOGLE_SERVICE_ACCOUNT_EMAIL
  • GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY
  • GOOGLE_SHEET_ID

Then share the Sheet with the service account as an Editor. In production, sign-ups fail if these aren't set (deliberately: better than silently losing them). Locally, the Sheet is skipped when they're unset.

Removed

  • FormstackForm, formstack.css, and the hidden-iframe redirect workaround.
  • The proxy branch that moved the email off Formstack's redirect URL. src/proxy.ts is back to exactly how it was before Add Build the Vote volunteer pages #110.

Testing

  • pnpm test:sheet: 5 tests against an in-memory fake of the Sheets API and Google's token endpoint, using a generated RSA key. They cover JWT signing, header creation, upsert by email (case-insensitive), the Sheet's own column order, adding missing columns, token reuse, and skipping the Sheet when unconfigured.
  • In the browser, locally: step 1 → step 2 → thanks. Earlier, with a stand-in for the Sheet, the row came out as expected: phone and postal code normalized, UTM tags in their columns, and step 2's answers in the same row.
  • Tested against the real Sheet (service account build-the-vote-sheet@build-the-vote.iam.gserviceaccount.com, project build-the-vote): a step 1 → step 2 sign-up from local dev produced one complete row (header row created, phone and postal code normalized, UTM tags and Partner filled, step 2 answers in the same row), confirmed by reading the Sheet back through the API.
  • eslint and tsc pass.

🤖 Generated with Claude Code

…e Sheet

Build the Vote sign-ups now use our own two-step form. /api/volunteer
writes each sign-up to a Google Sheet shared with CJPAC via the Sheets
API (as a service account, signed with jose; no new dependencies) and
identifies the volunteer to Customer.io. Step 2's optional answers go to
/api/volunteer/details, which fills in the same row, found by an httpOnly
cookie step 1 set, so the email never appears in a URL or the page.

Removes FormstackForm, its stylesheet, and the proxy branch that moved
the email off Formstack's redirect URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@xrendan
xrendan merged commit 57e2795 into main Oct 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant