feat: /volunteer and /speakers forms with Google Sheets + email#1330
Conversation
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 is attempting to deploy a commit to the Codú Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
WalkthroughAdds 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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.jsonpackage.jsonis excluded by!**/*.json
📒 Files selected for processing (16)
app/(app)/speakers/_client.tsxapp/(app)/speakers/page.tsxapp/(app)/volunteer/_client.tsxapp/(app)/volunteer/page.tsxapp/sitemap.tscomponents/Speaker/SpeakerForm.tsxcomponents/Volunteer/VolunteerForm.tsxconfig/submissions.tsschema/speaker.tsschema/volunteer.tsserver/api/router/index.tsserver/api/router/speaker.tsserver/api/router/volunteer.tsutils/createSpeakerApplicationEmailTemplate.tsutils/createVolunteerApplicationEmailTemplate.tsutils/googleSheets.ts
| applicantLocationRequirements: { "@type": "Country", name: "Worldwide" }, | ||
| jobLocationType: "TELECOMMUTE", | ||
| datePosted: new Date().toISOString().split("T")[0], |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and read the file
find . -name "page.tsx" -path "*/volunteer/*" -type fRepository: 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 -nRepository: 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:
- 1: https://developers.google.com/search/docs/appearance/structured-data/job-posting
- 2: https://developers.google.cn/search/docs/appearance/structured-data/job-posting
- 3: https://google.schema.org/JobPosting
- 4: https://google.schema.org/applicantLocationRequirements
🌐 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:
- 1: https://schema.org/applicantLocationRequirements
- 2: https://validator.schema.org/JobPosting
- 3: http://meta.schema.org/JobPosting
- 4: https://schema.org/JobPosting
- 5: https://developers.google.com/search/docs/appearance/structured-data/job-posting
- 6: https://schema.org/Country
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.
| 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.
| .or(z.literal("").transform(() => undefined)), | ||
| other: optionalString(2000), | ||
| // Honeypot — must be empty | ||
| website: z.string().max(0).optional().or(z.literal("")), |
There was a problem hiding this comment.
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.
| 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.
| // 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.", | ||
| }; | ||
| } |
There was a problem hiding this comment.
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.
| await sheets.spreadsheets.values.append({ | ||
| spreadsheetId, | ||
| range: `${tab}!A1`, | ||
| valueInputOption: "USER_ENTERED", | ||
| insertDataOption: "INSERT_ROWS", | ||
| requestBody: { values: [normalized] }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "googleSheets.ts" | head -20Repository: codu-code/codu
Length of output: 81
🏁 Script executed:
cat -n ./utils/googleSheets.tsRepository: codu-code/codu
Length of output: 1970
🏁 Script executed:
rg "appendRowToSubmissionsSheet" -A 3 -B 3 --type ts --type tsx --type js --type jsxRepository: codu-code/codu
Length of output: 85
🏁 Script executed:
rg "appendRowToSubmissionsSheet" -A 3 -B 3Repository: codu-code/codu
Length of output: 2437
🏁 Script executed:
sed -n '1,100p' ./server/api/router/volunteer.tsRepository: codu-code/codu
Length of output: 3067
🏁 Script executed:
rg "VolunteerApplicationSchema" -A 20 | head -60Repository: codu-code/codu
Length of output: 3399
🏁 Script executed:
cat -n ./schema/volunteer.tsRepository: codu-code/codu
Length of output: 2626
🏁 Script executed:
sed -n '1,80p' ./server/api/router/speaker.tsRepository: codu-code/codu
Length of output: 2475
🏁 Script executed:
sed -n '75,130p' ./server/api/router/speaker.tsRepository: 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.
| 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>
There was a problem hiding this comment.
♻️ Duplicate comments (1)
server/api/router/volunteer.ts (1)
18-25:⚠️ Potential issue | 🟡 MinorHoneypot silent-success branch is still unreachable.
VolunteerApplicationSchema.websiterejects 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 forwebsite, 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
📒 Files selected for processing (8)
app/(app)/speakers/page.tsxapp/(app)/volunteer/page.tsxserver/api/router/speaker.tsserver/api/router/volunteer.tsutils/createSpeakerApplicationEmailTemplate.tsutils/createSponsorInquiryEmailTemplate.tsutils/createVolunteerApplicationEmailTemplate.tsutils/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>
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:
Promise.allSettled— one channel failing doesn't block the other.ui-components/Input,Textarea,Field,ErrorMessage).safeParse, matching the sponsor form pattern) on the client and again via tRPC input schemas on the server.Google Sheets plumbing
utils/googleSheets.tsexposes a genericappendRowToSubmissionsSheet({ tab, values })using a single service-account JWT (lazy, cached). Tab names live inconfig/submissions.tsso 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
.envcomments):GOOGLE_SHEETS_CLIENT_EMAILGOOGLE_SHEETS_PRIVATE_KEYSUBMISSIONS_SHEET_IDOne service account authenticates all forms. The sheet has one tab per form (
Volunteers,Speakers,Advertisers— last one reserved for future wiring).SEO
JobPostingJSON-LD withemploymentType: VOLUNTEERfor richer SERP cards.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
.envcomments), share with the service-account email./volunteer, submit a full form → row appended toVolunteerstab, admin email received./speakers, submit with 1 talk, 2 talks, and 3 talks → rows appended toSpeakerstab with unused talk slots empty, admin email received with talks rendered.SUBMISSIONS_SHEET_ID→ email still arrives, sheet error logged to console + Sentry, user sees success.npm run lintandnpx tsc --noEmitclean.🤖 Generated with Claude Code