Skip to content

feat: /volunteer and /speakers forms with Google Sheets + email#1330

Merged
NiallJoeMaher merged 3 commits intocodu-code:developfrom
NiallJoeMaher:feat/volunteer-speaker-forms
Apr 21, 2026
Merged

feat: /volunteer and /speakers forms with Google Sheets + email#1330
NiallJoeMaher merged 3 commits intocodu-code:developfrom
NiallJoeMaher:feat/volunteer-speaker-forms

Conversation

@NiallJoeMaher
Copy link
Copy Markdown
Contributor

Summary

Two new public recruitment pages with form submissions that write to a shared Google Sheet and email an admin notification:

  • /volunteer — marketing/events volunteer sign-up.
  • /speakers — pitch a talk, supports 1–3 talks per submission via a dynamic repeater (useFieldArray).

Both forms:

  • Submit via a tRPC mutation.
  • Append a row to a shared "Codú Submissions" Google Sheet tab and send an admin email in parallel via Promise.allSettled — one channel failing doesn't block the other.
  • Include an invisible honeypot to catch naive bots.
  • Match the existing dark-theme Tailwind UI (reusing ui-components/Input, Textarea, Field, ErrorMessage).
  • Validate with Zod (manual safeParse, matching the sponsor form pattern) on the client and again via tRPC input schemas on the server.

Google Sheets plumbing

utils/googleSheets.ts exposes a generic appendRowToSubmissionsSheet({ tab, values }) using a single service-account JWT (lazy, cached). Tab names live in config/submissions.ts so they're consistent across environments; only the sheet ID and service-account creds come from env.

Required env vars (see the setup walkthrough pasted into .env comments):

  • GOOGLE_SHEETS_CLIENT_EMAIL
  • GOOGLE_SHEETS_PRIVATE_KEY
  • SUBMISSIONS_SHEET_ID

One service account authenticates all forms. The sheet has one tab per form (Volunteers, Speakers, Advertisers — last one reserved for future wiring).

SEO

  • Keyword-rich metadata + OG/Twitter cards on both pages.
  • JobPosting JSON-LD with employmentType: VOLUNTEER for richer SERP cards.
  • Both routes added to sitemap.ts.

Spam — noted for follow-up

Honeypot only for now. Suggested next tier when needed: Upstash-backed rate limit on the two mutations, then Cloudflare Turnstile if that isn't enough.

Test plan

  • Set up the Google Sheet + service account (walkthrough in .env comments), share with the service-account email.
  • Visit /volunteer, submit a full form → row appended to Volunteers tab, admin email received.
  • Visit /speakers, submit with 1 talk, 2 talks, and 3 talks → rows appended to Speakers tab with unused talk slots empty, admin email received with talks rendered.
  • Submit invalid data → inline field errors appear, form doesn't hit the server.
  • Break SUBMISSIONS_SHEET_ID → email still arrives, sheet error logged to console + Sentry, user sees success.
  • npm run lint and npx tsc --noEmit clean.

🤖 Generated with Claude Code

Two public recruitment pages for the Codú community:

- /volunteer — marketing/events volunteer sign-up
- /speakers — pitch a talk (1–3 talks per submission, dynamic repeater)

Both forms:
- Submit via a tRPC mutation (volunteer.submit / speaker.submit)
- Append a row to a shared "Codú Submissions" Google Sheet tab, and
  send an admin email notification in parallel via Promise.allSettled
  so a failure in one channel doesn't block the other
- Include an invisible honeypot field to catch naive bots
- Match the existing dark-theme Tailwind UI and use the ui-components
  primitives (Input, Textarea, Field, ErrorMessage)
- Validate client-side with Zod (manual safeParse, matching the sponsor
  form pattern) and server-side via tRPC input schemas

Google Sheets plumbing is generic — utils/googleSheets.ts exposes
appendRowToSubmissionsSheet({ tab, values }) using a single service-
account JWT, lazily instantiated and cached. Tab names live in
config/submissions.ts so they're consistent across environments;
only the sheet ID and service-account creds come from env.

SEO:
- Keyword-rich metadata + OG/Twitter cards on both pages
- JobPosting JSON-LD (employmentType: VOLUNTEER) for richer SERP cards
- /volunteer and /speakers added to sitemap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NiallJoeMaher NiallJoeMaher requested a review from a team as a code owner April 21, 2026 11:02
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 21, 2026

@NiallJoeMaher is attempting to deploy a commit to the Codú Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 21, 2026

Warning

Rate limit exceeded

@NiallJoeMaher has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 38 minutes and 4 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 38 minutes and 4 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4de4cc47-471f-4f5c-9e18-c3d83af39157

📥 Commits

Reviewing files that changed from the base of the PR and between c1044bc and 183c450.

📒 Files selected for processing (5)
  • app/(app)/volunteer/page.tsx
  • components/Speaker/SpeakerForm.tsx
  • components/Volunteer/VolunteerForm.tsx
  • schema/speaker.ts
  • schema/volunteer.ts

Walkthrough

Adds speaker and volunteer submission pipelines: client pages and forms, Zod schemas, TRPC routers, email template generators, Google Sheets append utility, helper sanitizers/escapers, sitemap and config updates, and wiring of new API routes into the main router.

Changes

Cohort / File(s) Summary
Pages & Client Entrypoints
app/(app)/speakers/page.tsx, app/(app)/speakers/_client.tsx, app/(app)/volunteer/page.tsx, app/(app)/volunteer/_client.tsx
New Next.js routes and client components for /speakers and /volunteer with typed metadata and client-rendered form containers.
Form Components
components/Speaker/SpeakerForm.tsx, components/Volunteer/VolunteerForm.tsx
New client-side React forms using react-hook-form, with honeypot inputs, Zod validation integration, mutation submission, submission state UI, and success views.
Validation Schemas
schema/speaker.ts, schema/volunteer.ts
New Zod schemas, label mappings, typed constants, optional-string helper, honeypot rules, and exported input types for speaker and volunteer submissions.
API Routers
server/api/router/speaker.ts, server/api/router/volunteer.ts, server/api/router/index.ts
New TRPC routers with submit mutations, honeypot short-circuit, prepared payloads, concurrent side effects (Google Sheets append + email send), per-step Sentry logging, and partial-failure handling; routers wired into appRouter.
Email Templates & Utils
utils/createSpeakerApplicationEmailTemplate.ts, utils/createVolunteerApplicationEmailTemplate.ts, utils/createSponsorInquiryEmailTemplate.ts, utils/escapeHtml.ts
New HTML email generators for speaker/volunteer (and sanitization updates to sponsor template); added escapeHtml and sanitizeHref helpers and usage to escape/sanitize interpolated values.
Google Sheets Integration
utils/googleSheets.ts
New Sheets client using service-account creds and exported appendRowToSubmissionsSheet helper to append normalized rows to a specified sheet tab.
Config & Sitemap
config/submissions.ts, app/sitemap.ts
New constants for spreadsheet tab names and sitemap updated to include /volunteer and /speakers.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/Browser
    participant Form as SpeakerForm (Client)
    participant TRPC as API (speaker.submit)
    participant Sheets as Google Sheets
    participant Email as Email Service
    participant Sentry as Sentry (Logging)

    User->>Form: Submit speaker application
    Form->>Form: Validate with SpeakerApplicationSchema
    alt Validation fails
        Form->>User: Display field errors
    else Validation succeeds
        Form->>TRPC: mutateAsync(validated data)
        TRPC->>TRPC: Honeypot check (website)
        alt Honeypot triggered
            TRPC->>User: Return success (silent)
        else Valid submission
            TRPC->>TRPC: Prepare payload & labels
            par Parallel execution
                TRPC->>Sheets: appendRowToSubmissionsSheet (SPEAKER_SHEET_TAB)
                TRPC->>Email: sendEmail (createSpeakerApplicationEmailTemplate)
            and
                Sheets-->>TRPC: Success / Error
                Email-->>TRPC: Success / Error
            end
            alt Both side-effects succeed
                TRPC->>User: Return success message
            else Both failed
                TRPC->>Sentry: Report both errors
                TRPC->>User: Throw INTERNAL_SERVER_ERROR
            else Partial failure
                TRPC->>Sentry: Report failing step(s)
                TRPC->>User: Return success message (partial-failure policy)
            end
        end
    end
Loading
sequenceDiagram
    participant User as User/Browser
    participant Form as VolunteerForm (Client)
    participant TRPC as API (volunteer.submit)
    participant Sheets as Google Sheets
    participant Email as Email Service
    participant Sentry as Sentry (Logging)

    User->>Form: Submit volunteer application
    Form->>Form: Validate with VolunteerApplicationSchema
    alt Validation fails
        Form->>User: Display field errors
    else Validation succeeds
        Form->>TRPC: mutateAsync(validated data)
        TRPC->>TRPC: Honeypot check (website)
        alt Honeypot triggered
            TRPC->>User: Return success (silent)
        else Valid submission
            TRPC->>TRPC: Prepare payload & labels
            par Parallel execution
                TRPC->>Sheets: appendRowToSubmissionsSheet (VOLUNTEER_SHEET_TAB)
                TRPC->>Email: sendEmail (createVolunteerApplicationEmailTemplate)
            and
                Sheets-->>TRPC: Success / Error
                Email-->>TRPC: Success / Error
            end
            alt Both side-effects succeed
                TRPC->>User: Return success message
            else Both failed
                TRPC->>Sentry: Report both errors
                TRPC->>User: Throw INTERNAL_SERVER_ERROR
            else Partial failure
                TRPC->>Sentry: Report failing step(s)
                TRPC->>User: Return success message (partial-failure policy)
            end
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I hopped through forms and Zod-lined trails,
I scribbled sheets and mailed the mails,
Two new routes bloom, validators cheer,
Submissions land, both far and near —
A little rabbit stamps them clear. 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: addition of /volunteer and /speakers recruitment forms integrated with Google Sheets and email notifications.
Description check ✅ Passed The PR description comprehensively covers objectives, implementation details, test plan, and env setup, significantly exceeding the template requirements despite the template's minimal structure.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/`(app)/speakers/page.tsx:
- Around line 56-58: The JobPosting metadata is incorrect: replace the dynamic
datePosted value (new Date().toISOString().split("T")[0]) with a fixed
publication date constant, and update location fields to match the meetup scope
by either setting jobLocationType to "ON_SITE" (if speakers are expected to
attend Dublin meetups) and changing applicantLocationRequirements from {
"@type": "Country", name: "Worldwide" } to a concrete country object for Ireland
(or a list of eligible countries), or keep TELECOMMUTE but update visible copy
and applicantLocationRequirements to a valid list of countries; adjust the
objects named applicantLocationRequirements, jobLocationType, and datePosted in
the page.tsx metadata accordingly.

In `@app/`(app)/volunteer/page.tsx:
- Line 1: This file fails the project's formatting rules; run Prettier on
app/(app)/volunteer/page.tsx to fix the formatting issues reported by the Code
Quality Checks pipeline — e.g., run the project's formatter script (npm run
format or yarn format) or run prettier --write against the file, ensuring you
use the repository's Prettier config so imports like the existing import type {
Metadata } from "next"; are reformatted to match project style before merging.
- Around line 56-58: Replace the invalid "Worldwide" value in the JobPosting
metadata by using a concrete country matching the address (use "Ireland" for
applicantLocationRequirements.name since addressCountry is "IE"), and stop
regenerating datePosted at build time — set datePosted to a fixed ISO date
string representing when the posting went live (e.g., "2026-04-01"); update the
applicantLocationRequirements and datePosted fields in the JobPosting object in
page.tsx (look for applicantLocationRequirements, jobLocationType, datePosted)
and if the role truly accepts multiple countries, explicitly list each country
name in applicantLocationRequirements instead of a generic value.

In `@components/Volunteer/VolunteerForm.tsx`:
- Line 1: Prettier formatting is failing for this file; run the formatter to fix
styling issues. Run `npx prettier --write` (or your editor's Prettier
integration) on components/Volunteer/VolunteerForm.tsx and the other flagged
files (components/Speaker/SpeakerForm.tsx, schema/speaker.ts,
schema/volunteer.ts) so the file starting with the "use client" directive is
reformatted to match the repository Prettier rules and unblock CI.

In `@schema/speaker.ts`:
- Line 102: The honeypot `website` field in the schemas is currently validated
with `z.string().max(0)...` which rejects any non-empty string and prevents the
router's silent-success branches from ever running; replace the `website` field
validation in the speaker schema (the `website` property on the speaker schema
object, e.g. inside `speakerSchema`) with a permissive validator such as
`z.string().optional()` so non-empty submissions reach the handler, and make the
identical change to the `website` field in the volunteer schema (the `website`
property on the `volunteerSchema`) so both routers can detect filled honeypots
at runtime.

In `@server/api/router/volunteer.ts`:
- Around line 18-25: The honeypot "silent success" branch is unreachable because
VolunteerApplicationSchema currently enforces website as z.string().max(0) so
tRPC rejects non-empty values before this handler sees them; either make the
schema accept filled honeypots (change VolunteerApplicationSchema.website to
z.string().optional() or similar in schema/volunteer.ts) so the handler receives
input.website, or remove the dead branch that checks input.website in the
volunteer route handler (the if (input.website && input.website.length > 0)
block) to avoid unreachable code. Ensure whichever choice you make keeps the
intended silent-fail behavior for bots.

In `@utils/createSpeakerApplicationEmailTemplate.ts`:
- Around line 38-118: The template dangerously interpolates user input in
createSpeakerApplicationEmailTemplate (and likewise in
createVolunteerApplicationEmailTemplate); fix by adding a small escapeHtml
helper and applying it to every interpolated field (details.name, details.email,
details.location, details.bio, details.other, details.talks[].title,
details.talks[].abstract, details.formatLabel, details.experienceLabel, etc.),
URL-encode mailto/query parts with encodeURIComponent for the Reply link/subject
and mailto hrefs, and validate/whitelist link hrefs (only allow http, https,
mailto) before rendering the <a href="${details.link}"> to avoid attribute/URL
injection; update sectionBlock and talkBlock usages to receive already-escaped
strings so all injected values are safe.

In `@utils/googleSheets.ts`:
- Around line 48-53: Change the value input mode used when appending rows to
Google Sheets to prevent formula injection: in the call to
sheets.spreadsheets.values.append (the block that passes spreadsheetId, range:
`${tab}!A1`, valueInputOption, insertDataOption, and requestBody with values:
[normalized]) replace valueInputOption: "USER_ENTERED" with valueInputOption:
"RAW" so user-submitted fields (normalized) are stored as literal text rather
than being interpreted as formulas.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7d25fc29-1f09-49f2-9d16-ba8b56c8a9b3

📥 Commits

Reviewing files that changed from the base of the PR and between ed3646f and e5b6cac.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (16)
  • app/(app)/speakers/_client.tsx
  • app/(app)/speakers/page.tsx
  • app/(app)/volunteer/_client.tsx
  • app/(app)/volunteer/page.tsx
  • app/sitemap.ts
  • components/Speaker/SpeakerForm.tsx
  • components/Volunteer/VolunteerForm.tsx
  • config/submissions.ts
  • schema/speaker.ts
  • schema/volunteer.ts
  • server/api/router/index.ts
  • server/api/router/speaker.ts
  • server/api/router/volunteer.ts
  • utils/createSpeakerApplicationEmailTemplate.ts
  • utils/createVolunteerApplicationEmailTemplate.ts
  • utils/googleSheets.ts

Comment thread app/(app)/speakers/page.tsx Outdated
Comment thread app/(app)/volunteer/page.tsx
Comment thread app/(app)/volunteer/page.tsx Outdated
Comment on lines +56 to +58
applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
jobLocationType: "TELECOMMUTE",
datePosted: new Date().toISOString().split("T")[0],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the file
find . -name "page.tsx" -path "*/volunteer/*" -type f

Repository: codu-code/codu

Length of output: 88


🏁 Script executed:

# Get more context around lines 56-58
sed -n '50,65p' "app/(app)/volunteer/page.tsx" | cat -n

Repository: codu-code/codu

Length of output: 563


🌐 Web query:

Google JobPosting structured data requirements applicantLocationRequirements

💡 Result:

Google's requirements for the applicantLocationRequirements property in JobPosting structured data are as follows: The property specifies the geographic location(s) from which applicants can apply, primarily for telecommuting or remote jobs. It is of type AdministrativeArea (e.g., Country, State) according to Schema.org. Key guidelines from official Google documentation: - Use it with jobLocationType: "TELECOMMUTE" for remote jobs. - At least one country is required for work-from-home jobs. - If omitted for remote jobs, Google defaults to the country in jobLocation. - Do not use for citizenship or work visa requirements (use eligibilityToWorkRequirement instead). - Can be an array for multiple locations. - Example: {"@type": "Country", "name": "USA"} It is recommended (not strictly required) for rich results eligibility, but essential for correct display of remote job restrictions to avoid validation errors in Search Console. Examples from docs: { "applicantLocationRequirements": { "@type": "Country", "name": "USA" }, "jobLocationType": "TELECOMMUTE" }

Citations:


🌐 Web query:

schema.org JobPosting Country applicantLocationRequirements valid values

💡 Result:

The schema.org JobPosting property applicantLocationRequirements specifies the location(s) from which applicants can apply, typically for telecommute jobs. Its expected type is AdministrativeArea, which includes Country as a subtype (since Country extends AdministrativeArea). When using Country for applicantLocationRequirements, valid values are instances of the Country type. There is no fixed enumerated list of valid countries; instead, use: - @type: "Country" - name: The country name (e.g., "USA") - Optionally, sameAs: A URL to an authoritative identifier like Wikidata (e.g., "https://www.wikidata.org/wiki/Q30" for USA) Examples from official schema.org documentation: { "@context": "https://schema.org/", "@type": "JobPosting", "name": "Telecommute from anywhere in USA!", "applicantLocationRequirements": { "@type": "Country", "sameAs": "https://www.wikidata.org/wiki/Q30", "name": "USA" } } Google's documentation confirms this usage for remote jobs, requiring at least one country. Note: Some older docs list only AdministrativeArea, but examples and current usage (v29.4+) support Country. It should not be used for citizenship/visa requirements (use eligibilityToWorkRequirement instead). Country itself follows ISO 3166 conventions (e.g., 2-letter codes in related properties like addressCountry), but for this property, structured markup with name and sameAs is preferred over codes.

Citations:


Use concrete JobPosting location metadata and a fixed posting date.

Worldwide is not a valid country value for applicantLocationRequirements. Google's JobPosting guidance requires at least one concrete country for remote roles (e.g., "USA", "Ireland"). Additionally, datePosted should not be dynamically regenerated on every build—it should be a fixed date representing when the posting actually went live. Google's documentation states the posting date should be genuine and only change when the job posting is actually updated.

Given the address uses addressCountry: "IE", the proposed fix using "Ireland" as the concrete location is appropriate:

Proposed fix
+const VOLUNTEER_POSTED_DATE = "2026-04-21";
+
 const volunteerJsonLd = {
   "@context": "https://schema.org",
   "@type": "JobPosting",
@@
-  applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
+  applicantLocationRequirements: { "@type": "Country", name: "Ireland" },
   jobLocationType: "TELECOMMUTE",
-  datePosted: new Date().toISOString().split("T")[0],
+  datePosted: VOLUNTEER_POSTED_DATE,
   url: PAGE_URL,
 };

If applicants are genuinely eligible from multiple countries, enumerate those countries explicitly and ensure the visible page copy clearly states the 100% remote arrangement.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
jobLocationType: "TELECOMMUTE",
datePosted: new Date().toISOString().split("T")[0],
const VOLUNTEER_POSTED_DATE = "2026-04-21";
const volunteerJsonLd = {
"@context": "https://schema.org",
"@type": "JobPosting",
applicantLocationRequirements: { "@type": "Country", name: "Ireland" },
jobLocationType: "TELECOMMUTE",
datePosted: VOLUNTEER_POSTED_DATE,
url: PAGE_URL,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/`(app)/volunteer/page.tsx around lines 56 - 58, Replace the invalid
"Worldwide" value in the JobPosting metadata by using a concrete country
matching the address (use "Ireland" for applicantLocationRequirements.name since
addressCountry is "IE"), and stop regenerating datePosted at build time — set
datePosted to a fixed ISO date string representing when the posting went live
(e.g., "2026-04-01"); update the applicantLocationRequirements and datePosted
fields in the JobPosting object in page.tsx (look for
applicantLocationRequirements, jobLocationType, datePosted) and if the role
truly accepts multiple countries, explicitly list each country name in
applicantLocationRequirements instead of a generic value.

Comment thread components/Volunteer/VolunteerForm.tsx
Comment thread schema/speaker.ts
.or(z.literal("").transform(() => undefined)),
other: optionalString(2000),
// Honeypot — must be empty
website: z.string().max(0).optional().or(z.literal("")),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Honeypot schema is too strict — the "silent success" path in the router can never run.

z.string().max(0).optional().or(z.literal("")) causes any non-empty website to fail tRPC input validation and return a 400 to the caller — which is exactly the signal a bot uses to learn the field is a honeypot. The routers' if (input.website && input.website.length > 0) silent-success branches (server/api/router/speaker.ts line 20, server/api/router/volunteer.ts line 19) are therefore unreachable.

Loosen the schema so a filled honeypot reaches the handler, e.g.:

-  // Honeypot — must be empty
-  website: z.string().max(0).optional().or(z.literal("")),
+  // Honeypot — accepted here, rejected (silently) in the router
+  website: z.string().optional(),

Apply the same change to schema/volunteer.ts line 72.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
website: z.string().max(0).optional().or(z.literal("")),
// Honeypot — accepted here, rejected (silently) in the router
website: z.string().optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@schema/speaker.ts` at line 102, The honeypot `website` field in the schemas
is currently validated with `z.string().max(0)...` which rejects any non-empty
string and prevents the router's silent-success branches from ever running;
replace the `website` field validation in the speaker schema (the `website`
property on the speaker schema object, e.g. inside `speakerSchema`) with a
permissive validator such as `z.string().optional()` so non-empty submissions
reach the handler, and make the identical change to the `website` field in the
volunteer schema (the `website` property on the `volunteerSchema`) so both
routers can detect filled honeypots at runtime.

Comment on lines +18 to +25
// Honeypot: bots fill every input, humans don't see this one.
if (input.website && input.website.length > 0) {
return {
success: true,
message:
"Thanks! We read every application and reply within 2 weeks.",
};
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Honeypot silent-success branch is unreachable.

Because VolunteerApplicationSchema.website is z.string().max(0)..., any non-empty value fails tRPC input validation before this handler runs and the bot gets a 400 response — revealing that the field is validated and defeating the "silent success" design of a honeypot. Either loosen the schema to z.string().optional() so this handler actually receives filled honeypots, or drop this dead branch. See the follow-up comment on schema/volunteer.ts line 72.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/router/volunteer.ts` around lines 18 - 25, The honeypot "silent
success" branch is unreachable because VolunteerApplicationSchema currently
enforces website as z.string().max(0) so tRPC rejects non-empty values before
this handler sees them; either make the schema accept filled honeypots (change
VolunteerApplicationSchema.website to z.string().optional() or similar in
schema/volunteer.ts) so the handler receives input.website, or remove the dead
branch that checks input.website in the volunteer route handler (the if
(input.website && input.website.length > 0) block) to avoid unreachable code.
Ensure whichever choice you make keeps the intended silent-fail behavior for
bots.

Comment thread utils/createSpeakerApplicationEmailTemplate.ts
Comment thread utils/googleSheets.ts
Comment on lines +48 to +53
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${tab}!A1`,
valueInputOption: "USER_ENTERED",
insertDataOption: "INSERT_ROWS",
requestBody: { values: [normalized] },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "googleSheets.ts" | head -20

Repository: codu-code/codu

Length of output: 81


🏁 Script executed:

cat -n ./utils/googleSheets.ts

Repository: codu-code/codu

Length of output: 1970


🏁 Script executed:

rg "appendRowToSubmissionsSheet" -A 3 -B 3 --type ts --type tsx --type js --type jsx

Repository: codu-code/codu

Length of output: 85


🏁 Script executed:

rg "appendRowToSubmissionsSheet" -A 3 -B 3

Repository: codu-code/codu

Length of output: 2437


🏁 Script executed:

sed -n '1,100p' ./server/api/router/volunteer.ts

Repository: codu-code/codu

Length of output: 3067


🏁 Script executed:

rg "VolunteerApplicationSchema" -A 20 | head -60

Repository: codu-code/codu

Length of output: 3399


🏁 Script executed:

cat -n ./schema/volunteer.ts

Repository: codu-code/codu

Length of output: 2626


🏁 Script executed:

sed -n '1,80p' ./server/api/router/speaker.ts

Repository: codu-code/codu

Length of output: 2475


🏁 Script executed:

sed -n '75,130p' ./server/api/router/speaker.ts

Repository: codu-code/codu

Length of output: 1762


Switch to RAW mode to prevent formula injection in user submissions.

User-provided form data (name, email, location, text responses) flows directly into Google Sheets with valueInputOption: "USER_ENTERED", which causes Sheets to interpret values like =1+1 as formulas. Since these are intake submissions meant for review, not user-facing calculations, use RAW to store values as literal text instead.

Proposed fix
   await sheets.spreadsheets.values.append({
     spreadsheetId,
     range: `${tab}!A1`,
-    valueInputOption: "USER_ENTERED",
+    valueInputOption: "RAW",
     insertDataOption: "INSERT_ROWS",
     requestBody: { values: [normalized] },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${tab}!A1`,
valueInputOption: "USER_ENTERED",
insertDataOption: "INSERT_ROWS",
requestBody: { values: [normalized] },
await sheets.spreadsheets.values.append({
spreadsheetId,
range: `${tab}!A1`,
valueInputOption: "RAW",
insertDataOption: "INSERT_ROWS",
requestBody: { values: [normalized] },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@utils/googleSheets.ts` around lines 48 - 53, Change the value input mode used
when appending rows to Google Sheets to prevent formula injection: in the call
to sheets.spreadsheets.values.append (the block that passes spreadsheetId,
range: `${tab}!A1`, valueInputOption, insertDataOption, and requestBody with
values: [normalized]) replace valueInputOption: "USER_ENTERED" with
valueInputOption: "RAW" so user-submitted fields (normalized) are stored as
literal text rather than being interpreted as formulas.

…artial-failure policy

Addresses the code review on PR codu-code#1330:

- Critical: email templates were interpolating user-submitted strings
  directly into HTML, leaking an XSS surface to the admin inbox. Added
  utils/escapeHtml.ts with escapeHtml() + sanitizeHref() (rejects non-
  http(s)/mailto URIs in href attributes) and wrapped every interpolation
  in the volunteer, speaker, AND sponsor templates. The sponsor template
  fix is pre-existing-bug cleanup included in the same pass per review.

- Medium: the JobPosting JSON-LD on /volunteer and /speakers violated
  Google's rich-results guidelines (JobPosting is for paid employment,
  was missing baseSalary and validThrough, and combined jobLocation with
  TELECOMMUTE jobLocationType). Risk of "Deceptive structured data"
  manual penalty. Removed both. Page-level metadata (title, description,
  OG, Twitter) is kept; the site-wide Organization JSON-LD from the app
  layout already covers brand-level structured data.

- High: documented the Promise.allSettled partial-failure policy in both
  routers — if one channel fails we still return success to the user and
  rely on Sentry + console.error to flag the gap; only both failing
  errors out. Previously implicit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
server/api/router/volunteer.ts (1)

18-25: ⚠️ Potential issue | 🟡 Minor

Honeypot silent-success branch is still unreachable.

VolunteerApplicationSchema.website rejects non-empty strings before this mutation runs, so bots filling the honeypot receive validation failure instead of this silent success. Loosen the schema to accept any string for website, then keep this branch, or remove the branch if silent success is not required.

Possible schema-side fix
-  website: z.string().max(0).optional().or(z.literal("")),
+  website: z.string().optional().or(z.literal("")),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/api/router/volunteer.ts` around lines 18 - 25, The honeypot
"silent-success" branch in the volunteer application mutation is unreachable
because VolunteerApplicationSchema currently rejects non-empty website values;
update the schema (VolunteerApplicationSchema) to accept any string for the
website field (make website a plain string/optional string instead of a
restricted one) so the mutation's honeypot check remains effective, and leave
the honeypot branch in the mutation (the if (input.website &&
input.website.length > 0) return { success: true, ... }) as-is; alternatively,
if you don't want silent-success behavior, remove that honeypot branch instead.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@server/api/router/volunteer.ts`:
- Around line 18-25: The honeypot "silent-success" branch in the volunteer
application mutation is unreachable because VolunteerApplicationSchema currently
rejects non-empty website values; update the schema (VolunteerApplicationSchema)
to accept any string for the website field (make website a plain string/optional
string instead of a restricted one) so the mutation's honeypot check remains
effective, and leave the honeypot branch in the mutation (the if (input.website
&& input.website.length > 0) return { success: true, ... }) as-is;
alternatively, if you don't want silent-success behavior, remove that honeypot
branch instead.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 6967d286-ccd6-41ab-844e-3c548e50ce30

📥 Commits

Reviewing files that changed from the base of the PR and between e5b6cac and c1044bc.

📒 Files selected for processing (8)
  • app/(app)/speakers/page.tsx
  • app/(app)/volunteer/page.tsx
  • server/api/router/speaker.ts
  • server/api/router/volunteer.ts
  • utils/createSpeakerApplicationEmailTemplate.ts
  • utils/createSponsorInquiryEmailTemplate.ts
  • utils/createVolunteerApplicationEmailTemplate.ts
  • utils/escapeHtml.ts
✅ Files skipped from review due to trivial changes (2)
  • app/(app)/speakers/page.tsx
  • server/api/router/speaker.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/(app)/volunteer/page.tsx

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NiallJoeMaher NiallJoeMaher merged commit 050aa81 into codu-code:develop Apr 21, 2026
3 of 5 checks 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