Grida Forms - Respondent Email Notifications#522
Conversation
…es, outlining their structure, usage guidelines, and best practices for creating reusable UI components and opinionated stateful modules. Include hard constraints and authoring guidelines to ensure consistency and maintainability across the codebase.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds per-form respondent email notifications: DB JSONB column and types, API endpoints and a submit-time hook to render/send emails, editor state/actions/UI and kits to configure templates, rendering utilities and tests, a migration, and documentation updates. Changes
Sequence DiagramsequenceDiagram
participant Client as Client/Form
participant Submit as Submit API
participant Hook as Notification Hook
participant DB as Database
participant Renderer as Email Renderer
participant Resend as Resend Service
Client->>Submit: POST /v1/submit/{form_id}
Submit->>DB: persist response
Submit->>Hook: call notification_respondent_email(form_id, response_id)
Hook->>DB: fetch form config (notification_respondent_email)
alt config missing or disabled
Hook-->>Submit: return 200 (skip)
else enabled
Hook->>DB: fetch response + customer
alt customer email missing/unverified
Hook-->>Submit: return 200 (skip)
else valid email
Hook->>Renderer: renderRespondentEmail(templates, data)
Renderer-->>Hook: {subject, html}
Hook->>Resend: send email (reply_to?, tags)
alt send success
Resend-->>Hook: success
else send failure
Resend-->>Hook: error -> Hook returns 500
end
end
end
Hook-->>Submit: return 200 OK
Submit-->>Client: 200 OK
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested labels
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 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 |
…detailing setup, customization, and troubleshooting steps. Update AGENTS.md to include forms documentation link.
Introduce a new reusable Email Template Authoring Kit that provides a consistent UI for composing emails, including fields for 'To', 'Reply-To', 'Subject', 'From name', 'From', and 'Body'. The kit features a controlled API for managing field states and values. Additionally, add a README to outline the kit's purpose, usage, and API details.
Introduce a new configuration for respondent email notifications in Grida Forms, allowing for customizable settings such as enabling/disabling notifications, specifying the sender's name, and defining email templates for subject and body. Implement API routes for updating these settings and handling email dispatch upon form submission. Additionally, add a UI component for managing these preferences within the editor, ensuring a seamless user experience for form administrators.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c5601ed80
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try { | ||
| OnSubmit.notification_respondent_email({ | ||
| form_id, | ||
| response_id: response_reference_obj.id, | ||
| }); |
There was a problem hiding this comment.
Await respondent email hook to avoid dropped sends
The new hook invocation is started without await, so the surrounding try/catch will not capture async failures and the submission handler can return before the fetch completes. In serverless/Edge runtimes, pending work is often terminated when the request ends, which means the respondent email may never be sent even when enabled. Consider awaiting the call or explicitly handling the promise (e.g. void ... .catch(...)) to ensure delivery/errors are handled.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Fix all issues with AI agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts:
- Around line 17-38: Replace throwing asserts and route-only notFound() with
explicit JSON responses and guard req.json() parsing: wrap the req.json() call
in try/catch and return NextResponse.json({ error: "Invalid JSON" }, { status:
400 }) on parse errors; validate extracted values (context.params.id /
response_id) and if missing return NextResponse.json({ error: "form_id required"
} or { error: "response_id required" }, { status: 400 }) instead of using
assert(); when the DB call for service_role.forms (the query selecting
id,title,notification_respondent_email,fields) returns no form, return
NextResponse.json({ error: "Not found" }, { status: 404 }) instead of calling
notFound(); keep existing console.error logging for form_err but ensure any
server-side failures still return a JSON 500 response via NextResponse.json({
error: "Internal Server Error" }, { status: 500 }).
- Around line 11-134: The endpoint allows replay sends because it never records
or checks whether a notification was already sent for a response; modify the
POST handler (function POST) to enforce idempotency by checking a
notification_sent_at column on the response row before sending and atomically
setting it when you send: 1) query the response
(service_role.forms.from("response") select id, notification_sent_at) and if
notification_sent_at exists return skipped; 2) when ready to send, perform an
atomic update that sets notification_sent_at = now() only if it is currently
null (use a conditional update or a DB transaction/row lock) and fail/skip if
the update affected no rows (indicating another request already sent); 3) only
call resend.emails.send after successfully claiming the send (or, if you must
send before updating, update immediately after and handle failures by clearing
or compensating); ensure all checks reference response.id/response_id and the
new notification_sent_at field so duplicate POSTs are skipped.
In `@editor/app/`(api)/(public)/v1/submit/[id]/route.ts:
- Around line 1117-1124: The current try/catch doesn't catch promise rejections
because the async hooks are invoked without awaiting; for each best-effort call
(OnSubmit.notification_respondent_email, OnSubmit.clearsession,
OnSubmit.postindexing) either await the call inside an async context or, if you
want non-blocking behavior, append a .catch(handler) to each invocation to
handle/log errors; update the three call sites to attach a catch that logs the
error (or otherwise handles it) so rejections don't become unhandled.
In
`@editor/app/`(api)/private/editor/settings/notification-respondent-email/route.ts:
- Around line 26-35: The update currently coerces missing optional fields to
null and overwrites notification_respondent_email JSONB (see
formsClient.from("form").update and the notification_respondent_email payload),
which wipes templates on partial updates; fix by first selecting the existing
notification_respondent_email for the target form, shallow-merge the existing
object with only the provided fields (only replace keys present in the request,
leave others untouched), then call formsClient.from("form").update with the
merged object (do not coerce undefined to null for absent fields unless explicit
clearing is intended). Ensure this logic lives in the same route handler in
route.ts that performs the update so partial updates preserve existing
templates.
- Around line 26-38: The update call to formsClient uses
.update(...).eq(...).single() which fails because .update defaults to Prefer:
return=minimal and returns no rows; add a .select(...) before .single() on the
query chain (e.g., call .select() or .select("notification_respondent_email") on
the result of formsClient.from("form").update(...)) so the update returns rows
and .single() can succeed.
In `@editor/app/`(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx:
- Around line 111-114: Update the sample SMS copy in the messages array inside
page.tsx: replace the misspelled string "Event is openning soon. Register now!"
(found in the messages prop) with the correct wording "Event is opening soon.
Register now!" so the sample message text used by the component is spelled
correctly.
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`:
- Around line 38-53: The code reads properties off
form.notification_respondent_email (assigned to initial) without guarding for
null; update the null guard so initial is ensured to be an object with safe
defaults (e.g. enabled: false, from_name: '', subject_template: '',
body_html_template: '', reply_to: '') before calling useForm. Replace the direct
reference to form.notification_respondent_email with a nullish-coalesced
fallback (or an explicit conditional) so useForm's defaultValues (enabled,
from_name, subject_template, body_html_template, reply_to) never access
properties on null; keep the same keys used in useForm for consistency.
- Around line 152-158: Sanitize the admin-authored HTML before using
dangerouslySetInnerHTML: import and use DOMPurify to call
DOMPurify.sanitize(body_html_template) (optionally with a strict config like
FORBID_TAGS for scripts and event handlers) and pass the sanitized string to the
__html property instead of the raw body_html_template; update the JSX block that
currently uses dangerouslySetInnerHTML to use the sanitizedHtml variable (and
ensure this happens where body_html_template is defined/used in the component).
In `@editor/scaffolds/sidebar/sidebar-mode-connect.tsx`:
- Around line 72-84: The new SidebarMenuLinkButton usage opens links with
target="_blank" but doesn't set rel, exposing window.opener; update the
component usage (or the SidebarMenuLinkButton implementation) to include
rel="noopener noreferrer" on external links — e.g., in the link prop passed to
SidebarMenuLinkButton (the object with href/target) add rel: "noopener
noreferrer" or modify SidebarMenuLinkButton to automatically add that rel when
target === "_blank". Ensure this change is applied to the instances around the
shown block and the other occurrences noted (lines ~300-312).
In `@editor/services/form/respondent-email.ts`:
- Around line 25-60: The function renderRespondentEmail currently calls
body_html_template.trim() but body_html_template can be null; change the
htmlSource assignment in renderRespondentEmail to safely handle null/undefined
(e.g., coalesce body_html_template to an empty string or a safe default before
calling trim) so trim never runs on null, and ensure render(htmlSource, ...)
receives that safe string; update any type annotations for body_html_template if
needed to reflect it may be nullable.
In `@supabase/migrations/20260204000000_grida_forms_respondent_email.sql`:
- Around line 5-12: The migration currently drops respondent-email columns
before consolidating them, which will lose data; instead, first ADD the new
notification_respondent_email jsonb column (notification_respondent_email) if
missing, then run an UPDATE on grida_forms.form to build a JSON object from the
existing columns (notification_respondent_email_enabled,
notification_respondent_email_to_attribute_id,
notification_respondent_email_subject_template,
notification_respondent_email_body_html_template,
notification_respondent_email_reply_to) taking care to handle NULLs and use the
existing default, and only after verifying/backfilling run DROP CONSTRAINT
form_notification_respondent_email_to_attribute_id_fkey and DROP COLUMN for the
old columns; ensure the UPDATE merges or sets keys consistently (enabled,
to_attribute_id, subject_template, body_html_template, reply_to) so existing
templates are preserved.
🧹 Nitpick comments (2)
editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx (1)
233-254: Consider consolidating repeated casts for readability.♻️ Suggested refactor
- notification_respondent_email: { - enabled: - ( - (form.notification_respondent_email ?? {}) as any - )?.enabled ?? false, - from_name: - ( - (form.notification_respondent_email ?? {}) as any - )?.from_name ?? null, - subject_template: - ( - (form.notification_respondent_email ?? {}) as any - )?.subject_template ?? null, - body_html_template: - ( - (form.notification_respondent_email ?? {}) as any - )?.body_html_template ?? null, - reply_to: - ( - (form.notification_respondent_email ?? {}) as any - )?.reply_to ?? null, - }, + notification_respondent_email: (() => { + const prefs = + (form.notification_respondent_email ?? {}) as Partial< + NonNullable<Form["notification_respondent_email"]> + >; + return { + enabled: prefs.enabled ?? false, + from_name: prefs.from_name ?? null, + subject_template: prefs.subject_template ?? null, + body_html_template: prefs.body_html_template ?? null, + reply_to: prefs.reply_to ?? null, + }; + })(),editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx (1)
102-142: Make the disabled SMS block non-focusable for keyboard/AT users.
pointer-events-noneblocks mouse input, but focusable controls can still be reached via keyboard or screen readers. Consider disabling nested controls or removing them from the tab order when the feature is marked as coming soon.
| export async function POST( | ||
| req: NextRequest, | ||
| context: { | ||
| params: Promise<Params>; | ||
| } | ||
| ) { | ||
| const { id: form_id } = await context.params; | ||
| const { response_id } = await req.json(); | ||
|
|
||
| assert(form_id, "form_id is required"); | ||
| assert(response_id, "response_id is required"); | ||
|
|
||
| const { data: form, error: form_err } = await service_role.forms | ||
| .from("form") | ||
| .select( | ||
| ` | ||
| id, | ||
| title, | ||
| notification_respondent_email, | ||
| fields:attribute(id, name, type) | ||
| ` | ||
| ) | ||
| .eq("id", form_id) | ||
| .single(); | ||
|
|
||
| if (form_err) | ||
| console.error("notification-respondent-email/err/form", form_err); | ||
| if (!form) return notFound(); | ||
|
|
||
| const cfg = (form.notification_respondent_email ?? {}) as Partial<{ | ||
| enabled: boolean; | ||
| from_name: string | null; | ||
| subject_template: string | null; | ||
| body_html_template: string | null; | ||
| reply_to: string | null; | ||
| }>; | ||
|
|
||
| if (!cfg.enabled) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "disabled" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { data: response, error: response_err } = await service_role.forms | ||
| .from("response") | ||
| .select("id, form_id, raw, local_index, local_id, customer_id") | ||
| .eq("id", response_id) | ||
| .eq("form_id", form_id) | ||
| .single(); | ||
|
|
||
| if (response_err) | ||
| console.error("notification-respondent-email/err/response", response_err); | ||
| if (!response) return notFound(); | ||
|
|
||
| const customer_id = response.customer_id?.trim() || null; | ||
| if (!customer_id) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "missing_customer" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { data: customer, error: customer_err } = await service_role.workspace | ||
| .from("customer") | ||
| .select("uid, email, is_email_verified") | ||
| .eq("uid", customer_id) | ||
| .single(); | ||
|
|
||
| if (customer_err) | ||
| console.error("notification-respondent-email/err/customer", customer_err); | ||
| if (!customer) return notFound(); | ||
|
|
||
| const to = (customer.email ?? "").trim(); | ||
| if (!to || !customer.is_email_verified || !validator.isEmail(to)) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "unverified_email" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const raw = (response.raw ?? {}) as Record<string, unknown>; | ||
|
|
||
| const htmlSource = cfg.body_html_template?.trim(); | ||
| if (!htmlSource) { | ||
| return NextResponse.json( | ||
| { ok: true, skipped: "missing_body_template" }, | ||
| { status: 200 } | ||
| ); | ||
| } | ||
|
|
||
| const { subject, html } = renderRespondentEmail({ | ||
| form_title: form.title, | ||
| raw, | ||
| response_local_index: Number(response.local_index ?? 0), | ||
| response_local_id: response.local_id ?? null, | ||
| subject_template: cfg.subject_template ?? null, | ||
| body_html_template: htmlSource, | ||
| }); | ||
|
|
||
| const replyTo = cfg.reply_to?.trim() || undefined; | ||
| const replyToSafe = | ||
| replyTo && validator.isEmail(replyTo) ? replyTo : undefined; | ||
|
|
||
| const fromName = cfg.from_name?.trim() || "Grida Forms"; | ||
|
|
||
| try { | ||
| await resend.emails.send({ | ||
| from: `${fromName} <no-reply@accounts.grida.co>`, | ||
| to: [to], | ||
| subject, | ||
| html, | ||
| replyTo: replyToSafe, | ||
| tags: [ | ||
| { name: "type", value: "notification_respondent_email" }, | ||
| { name: "form_id", value: form_id }, | ||
| ], | ||
| }); | ||
| } catch (e) { | ||
| console.error("notification-respondent-email/err/send", e); | ||
| return NextResponse.json({ ok: false }, { status: 500 }); | ||
| } | ||
|
|
||
| return NextResponse.json({ ok: true }, { status: 200 }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check the directory structure around this endpoint
echo "=== Directory structure of public submit API ==="
find editor/app/\(api\)/\(public\)/v1/submit -type f -name "*.ts" | head -30
</parameter>Repository: gridaco/grida
Length of output: 652
🏁 Script executed:
#!/bin/bash
# Look for how this endpoint is called - client-side invocations
echo "=== Search for POST calls to notification-respondent-email ==="
rg -n "notification-respondent-email" -g '*.{ts,tsx,js,jsx}' -A 3 -B 3 --max-count=15
echo ""
echo "=== Search for where response_id is passed to the client ==="
rg -n "response_id" -g '*.{ts,tsx,js,jsx}' -B 2 -A 2 | head -50
</parameter>Repository: gridaco/grida
Length of output: 11615
🏁 Script executed:
#!/bin/bash
# Check for idempotency or rate limiting mechanisms
echo "=== Search for notification_sent_at, idempotency, or rate limit flags ==="
rg -n "(notification_sent|idempotenc|rate_limit|sent_at)" -g '*.{ts,tsx}' --max-count=30
echo ""
echo "=== Check the hooks.ts file to see how the endpoint is called ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts | head -80
</parameter>Repository: gridaco/grida
Length of output: 2846
🏁 Script executed:
#!/bin/bash
# Check the main submit route to see context of hook invocation
echo "=== Check how notification hook is invoked in main submit handler ==="
rg -B 5 -A 5 "notification-respondent-email" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -50
</parameter>Repository: gridaco/grida
Length of output: 555
🏁 Script executed:
#!/bin/bash
# Check if the fetch call has any auth headers or signing
echo "=== Full hooks.ts to check for auth/signing ==="
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts
echo ""
echo "=== Check response table schema for notification tracking ==="
rg -n "response" -g '*.ts' "database.types" | grep -i "notification\|sent" | head -20
</parameter>Repository: gridaco/grida
Length of output: 4686
🏁 Script executed:
#!/bin/bash
# Check if response_id is truly public/exposed
echo "=== Search for where response is returned to client after submit ==="
rg -B 3 -A 3 "response_reference_obj.id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -40
</parameter>Repository: gridaco/grida
Length of output: 1279
🏁 Script executed:
#!/bin/bash
# Check the response table schema to confirm no notification tracking exists
echo "=== Search database schema files ==="
find editor -name "*.ts" -path "*database*" -o -name "database.types.ts" 2>/dev/null | head -10
echo ""
echo "=== Search for response table type definition ==="
rg -n "interface.*[Rr]esponse\|type.*[Rr]esponse" -g '*.ts' --max-count=5 | grep -v node_modules | head -20
</parameter>Repository: gridaco/grida
Length of output: 320
🏁 Script executed:
#!/bin/bash
# Check if there's any rate limiting middleware or request signing on the hook
echo "=== Check for middleware or auth on the hook route ==="
ls -la editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/
echo ""
echo "=== Look for any signing or secret verification in route ==="
cat editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -150
</parameter>Repository: gridaco/grida
Length of output: 4242
🏁 Script executed:
#!/bin/bash
# Verify if the endpoint is truly publicly accessible with no auth
echo "=== Check if there's a middleware.ts that might protect the route ==="
find editor/app/\(api\)/\(public\)/v1/submit -name "middleware.ts" 2>/dev/null
echo ""
echo "=== Check main middleware.ts for route protection ==="
rg -n "notification-respondent-email\|/v1/submit.*hooks" editor/middleware.ts 2>/dev/null || echo "Not in main middleware"
echo ""
echo "=== Look for any rate limiting library imports in route ==="
rg -n "rate|limit|throttle|ratelimit" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts
</parameter>Repository: gridaco/grida
Length of output: 390
🏁 Script executed:
#!/bin/bash
# Check if there's request signing or HMAC verification anywhere
echo "=== Search for HMAC, signature, or secret verification in the codebase ==="
rg -n "hmac|signature|crypto.*sign|verify.*sign" -g '*.ts' editor/app/\(api\)/\(public\)/v1/submit --max-count=10
</parameter>Repository: gridaco/grida
Length of output: 254
🏁 Script executed:
#!/bin/bash
# Check if the endpoint requires a secret/token header
echo "=== Check all imports and dependencies in the route ==="
head -10 editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts
echo ""
echo "=== Look for environment variables or secrets in the route file ==="
rg -n "process.env\|SECRET\|TOKEN\|AUTH" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.tsRepository: gridaco/grida
Length of output: 549
🏁 Script executed:
#!/bin/bash
# Verify the form_id parameter comes from URL (not from request body)
echo "=== Confirm form_id source in route ==="
rg -B 2 -A 2 "context.params\|form_id" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | head -20Repository: gridaco/grida
Length of output: 96
Public hook can be abused to resend notifications repeatedly.
This endpoint is under a public submit path with no auth, signing, rate limiting, or idempotency tracking. The response_id is exposed in the form completion URL (as a query parameter), making it discoverable. Anyone with the form_id and response_id can replay requests to re-trigger emails indefinitely. While the endpoint validates that the response belongs to the given form, there is no mechanism to prevent duplicate sends for the same response.
Add an idempotency key (one per response), track notification_sent_at to skip re-sends, or implement rate limiting to mitigate replay attacks.
🤖 Prompt for AI Agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts
around lines 11 - 134, The endpoint allows replay sends because it never records
or checks whether a notification was already sent for a response; modify the
POST handler (function POST) to enforce idempotency by checking a
notification_sent_at column on the response row before sending and atomically
setting it when you send: 1) query the response
(service_role.forms.from("response") select id, notification_sent_at) and if
notification_sent_at exists return skipped; 2) when ready to send, perform an
atomic update that sets notification_sent_at = now() only if it is currently
null (use a conditional update or a DB transaction/row lock) and fail/skip if
the update affected no rows (indicating another request already sent); 3) only
call resend.emails.send after successfully claiming the send (or, if you must
send before updating, update immediately after and handle failures by clearing
or compensating); ensure all checks reference response.id/response_id and the
new notification_sent_at field so duplicate POSTs are skipped.
| const { id: form_id } = await context.params; | ||
| const { response_id } = await req.json(); | ||
|
|
||
| assert(form_id, "form_id is required"); | ||
| assert(response_id, "response_id is required"); | ||
|
|
||
| const { data: form, error: form_err } = await service_role.forms | ||
| .from("form") | ||
| .select( | ||
| ` | ||
| id, | ||
| title, | ||
| notification_respondent_email, | ||
| fields:attribute(id, name, type) | ||
| ` | ||
| ) | ||
| .eq("id", form_id) | ||
| .single(); | ||
|
|
||
| if (form_err) | ||
| console.error("notification-respondent-email/err/form", form_err); | ||
| if (!form) return notFound(); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.tsRepository: gridaco/grida
Length of output: 4778
🌐 Web query:
Next.js 16 notFound() function API routes JSON response
💡 Result:
notFound() is meant for App Router UI rendering: it throws an internal 404 error so Next.js renders your segment’s not-found page (it’s not a “return a JSON 404” helper). [1]
For an API endpoint (Route Handler in app/api/**/route.ts), return a normal 404 response with JSON:
// app/api/users/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) return Response.json({ error: "Not found" }, { status: 404 })
return Response.json(user)
}Response.json(...) / NextResponse.json(...) is the intended way to send JSON from Route Handlers. [2]
If you’re using Pages Router API routes (pages/api/...), do:
res.status(404).json({ error: "Not found" })Sources
[1] Next.js Docs – notFound() API reference https://nextjs.org/docs/app/api-reference/functions/not-found
[2] Next.js Docs – Route Handlers (returning JSON via Response.json) https://nextjs.org/docs/app/getting-started/route-handlers-and-middleware
🌐 Web query:
Next.js 16 request.json() error handling try-catch
💡 Result:
In Next.js 16 Route Handlers (app/api/**/route.ts), request.json() is just the standard Web Request.json() method. It returns a Promise and can throw:
SyntaxErrorif the body is not valid JSON (or is empty but you try to parse it as JSON) (developer.mozilla.org)TypeErrorif the body was already read / is locked (“disturbed”) (developer.mozilla.org)
Minimal pattern (catch invalid JSON → 400)
// app/api/foo/route.ts
export async function POST(request: Request) {
let body: unknown;
try {
body = await request.json(); // call ONCE
} catch (err) {
return Response.json(
{ error: "Invalid JSON" },
{ status: 400 }
);
}
return Response.json({ ok: true, body });
}Next.js explicitly documents reading the body this way in Route Handlers. (nextjs.org)
Avoid “Body is unusable” (don’t read twice)
If you do something like console.log(await request.json()) and then later await request.json() again, the second read can fail because the body stream is already consumed. (reddit.com)
If you need the body twice, read it once and reuse the value, or use request.clone() before reading.
If you want to treat empty body as “no JSON” (optional)
const text = await request.text();
if (!text) return Response.json({ ok: true, body: null });
let body;
try { body = JSON.parse(text); }
catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); }Citations:
- 1: https://developer.mozilla.org/en-US/docs/Web/API/Request/json?utm_source=openai
- 2: https://developer.mozilla.org/en-US/docs/Web/API/Request/json?utm_source=openai
- 3: https://nextjs.org/docs/app/building-your-application/routing/route-handlers?utm_source=openai
- 4: https://www.reddit.com/r/nextjs/comments/1cdrkys?utm_source=openai
Return explicit JSON 400/404 instead of throwing.
assert() raises 500s on bad input, and notFound() is designed for page routing (not JSON API responses). Additionally, req.json() can throw SyntaxError on invalid JSON, which will also result in a 500 response instead of a proper 400 error. Use try-catch for JSON parsing and return NextResponse.json() with appropriate status codes for validation failures and missing resources.
✅ Suggested error handling pattern
@@
-import assert from "assert";
-import { notFound } from "next/navigation";
@@
const { id: form_id } = await context.params;
- const { response_id } = await req.json();
-
- assert(form_id, "form_id is required");
- assert(response_id, "response_id is required");
+ let body: { response_id?: string };
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json(
+ { ok: false, error: "invalid_json" },
+ { status: 400 }
+ );
+ }
+ const { response_id } = body ?? {};
+ if (!form_id || !response_id) {
+ return NextResponse.json(
+ { ok: false, error: "missing_params" },
+ { status: 400 }
+ );
+ }
@@
- if (!form) return notFound();
+ if (!form)
+ return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });Also applies to: 62-65, 82-83
🤖 Prompt for AI Agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts
around lines 17 - 38, Replace throwing asserts and route-only notFound() with
explicit JSON responses and guard req.json() parsing: wrap the req.json() call
in try/catch and return NextResponse.json({ error: "Invalid JSON" }, { status:
400 }) on parse errors; validate extracted values (context.params.id /
response_id) and if missing return NextResponse.json({ error: "form_id required"
} or { error: "response_id required" }, { status: 400 }) instead of using
assert(); when the DB call for service_role.forms (the query selecting
id,title,notification_respondent_email,fields) returns no form, return
NextResponse.json({ error: "Not found" }, { status: 404 }) instead of calling
notFound(); keep existing console.error logging for form_err but ensure any
server-side failures still return a JSON 500 response via NextResponse.json({
error: "Internal Server Error" }, { status: 500 }).
| // respondent email hook (best-effort) | ||
| // TODO: move to PGMQ/jobs for retryable delivery | ||
| try { | ||
| OnSubmit.notification_respondent_email({ | ||
| form_id, | ||
| response_id: response_reference_obj.id, | ||
| }); | ||
| } catch (e) { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | sed -n '1115,1130p'Repository: gridaco/grida
Length of output: 580
🏁 Script executed:
# Find where OnSubmit is defined/imported
rg -n "OnSubmit" editor/app/\(api\)/\(public\)/v1/submit/\[id\]/route.ts | head -20Repository: gridaco/grida
Length of output: 321
🏁 Script executed:
# Search for notification_respondent_email definition
rg -n "notification_respondent_email" editor/ --type ts --type tsx -B 2 -A 5Repository: gridaco/grida
Length of output: 84
🏁 Script executed:
find editor/app/\(api\)/\(public\)/v1/submit/\[id\]/ -name "hooks*" -type fRepository: gridaco/grida
Length of output: 106
🏁 Script executed:
# Look for hooks file in the submit directory
ls -la editor/app/\(api\)/\(public\)/v1/submit/\[id\]/Repository: gridaco/grida
Length of output: 462
🏁 Script executed:
# Search for hooks.ts or hooks.js in the editor directory
fd "hooks" editor/app/\(api\)/\(public\)/v1/submit/Repository: gridaco/grida
Length of output: 154
🏁 Script executed:
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.tsRepository: gridaco/grida
Length of output: 4397
Handle the async hook explicitly (current try/catch won't catch rejections).
Line 1120 invokes an async function without await or a .catch(), so promise rejections bypass the try/catch and become unhandled rejections. For best-effort behavior, attach a catch handler:
✅ Non-blocking best-effort fix
- try {
- OnSubmit.notification_respondent_email({
- form_id,
- response_id: response_reference_obj.id,
- });
- } catch (e) {
- console.error("submit/err/hooks/notification-respondent-email", e);
- }
+ void OnSubmit.notification_respondent_email({
+ form_id,
+ response_id: response_reference_obj.id,
+ }).catch((e) => {
+ console.error("submit/err/hooks/notification-respondent-email", e);
+ });Note: OnSubmit.clearsession() (line 1098) and OnSubmit.postindexing() (line 1109) have the same issue.
📝 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.
| // respondent email hook (best-effort) | |
| // TODO: move to PGMQ/jobs for retryable delivery | |
| try { | |
| OnSubmit.notification_respondent_email({ | |
| form_id, | |
| response_id: response_reference_obj.id, | |
| }); | |
| } catch (e) { | |
| // respondent email hook (best-effort) | |
| // TODO: move to PGMQ/jobs for retryable delivery | |
| void OnSubmit.notification_respondent_email({ | |
| form_id, | |
| response_id: response_reference_obj.id, | |
| }).catch((e) => { | |
| console.error("submit/err/hooks/notification-respondent-email", e); | |
| }); |
🤖 Prompt for AI Agents
In `@editor/app/`(api)/(public)/v1/submit/[id]/route.ts around lines 1117 - 1124,
The current try/catch doesn't catch promise rejections because the async hooks
are invoked without awaiting; for each best-effort call
(OnSubmit.notification_respondent_email, OnSubmit.clearsession,
OnSubmit.postindexing) either await the call inside an async context or, if you
want non-blocking behavior, append a .catch(handler) to each invocation to
handle/log errors; update the three call sites to attach a catch that logs the
error (or otherwise handles it) so rejections don't become unhandled.
| const { error } = await formsClient | ||
| .from("form") | ||
| .update({ | ||
| notification_respondent_email: { | ||
| enabled, | ||
| from_name: from_name ?? null, | ||
| subject_template: subject_template ?? null, | ||
| body_html_template: body_html_template ?? null, | ||
| reply_to: reply_to ?? null, | ||
| }, |
There was a problem hiding this comment.
Prevent unintended clearing of respondent email settings on partial updates.
Because optional fields are coerced to null, a request that only toggles enabled will wipe existing templates. Either require full payloads or merge with the existing JSONB config before updating.
🛠️ Example merge-before-update approach
- const { error } = await formsClient
+ const { data: formRow, error: fetchError } = await formsClient
+ .from("form")
+ .select("notification_respondent_email")
+ .eq("id", form_id)
+ .single();
+
+ if (fetchError) {
+ console.error(fetchError);
+ return notFound();
+ }
+
+ const nextConfig = {
+ ...(formRow?.notification_respondent_email ?? {}),
+ enabled,
+ ...(from_name !== undefined ? { from_name } : {}),
+ ...(subject_template !== undefined ? { subject_template } : {}),
+ ...(body_html_template !== undefined ? { body_html_template } : {}),
+ ...(reply_to !== undefined ? { reply_to } : {}),
+ };
+
+ const { error } = await formsClient
.from("form")
.update({
- notification_respondent_email: {
- enabled,
- from_name: from_name ?? null,
- subject_template: subject_template ?? null,
- body_html_template: body_html_template ?? null,
- reply_to: reply_to ?? null,
- },
+ notification_respondent_email: nextConfig,
})
.eq("id", form_id)
.single();🤖 Prompt for AI Agents
In
`@editor/app/`(api)/private/editor/settings/notification-respondent-email/route.ts
around lines 26 - 35, The update currently coerces missing optional fields to
null and overwrites notification_respondent_email JSONB (see
formsClient.from("form").update and the notification_respondent_email payload),
which wipes templates on partial updates; fix by first selecting the existing
notification_respondent_email for the target form, shallow-merge the existing
object with only the provided fields (only replace keys present in the request,
leave others untouched), then call formsClient.from("form").update with the
merged object (do not coerce undefined to null for absent fields unless explicit
clearing is intended). Ensure this logic lives in the same route handler in
route.ts that performs the update so partial updates preserve existing
templates.
editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts
Show resolved
Hide resolved
| const initial = form.notification_respondent_email; | ||
|
|
||
| const { | ||
| handleSubmit, | ||
| control, | ||
| setValue, | ||
| formState: { isSubmitting, isDirty }, | ||
| reset, | ||
| } = useForm<FormValues>({ | ||
| defaultValues: { | ||
| enabled: initial.enabled, | ||
| from_name: initial.from_name, | ||
| subject_template: initial.subject_template, | ||
| body_html_template: initial.body_html_template, | ||
| reply_to: initial.reply_to, | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | head -60Repository: gridaco/grida
Length of output: 2417
🏁 Script executed:
rg -n "notification_respondent_email" editor -g '*.{ts,tsx}' -B 2 -A 2Repository: gridaco/grida
Length of output: 9257
🏁 Script executed:
rg -n "type FormValues\|interface FormValues" editor/scaffolds/settings/notification-respondent-email-preferences.tsx -A 10Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
rg -n "notification_respondent_email" editor/scaffolds/editor/state.ts -B 2 -A 8Repository: gridaco/grida
Length of output: 996
🏁 Script executed:
cat -n editor/scaffolds/editor/init.ts | grep -A 5 -B 5 "notification_respondent_email"Repository: gridaco/grida
Length of output: 487
🏁 Script executed:
rg -n "notification_respondent_email" editor/grida-forms-hosted/types.ts -B 2 -A 8Repository: gridaco/grida
Length of output: 373
🏁 Script executed:
rg -n "const form = " editor/scaffolds/settings/notification-respondent-email-preferences.tsx -B 5 -A 5Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
rg -n "useEditorState" editor/scaffolds/editor/index.ts -A 3Repository: gridaco/grida
Length of output: 131
🏁 Script executed:
rg -n "export.*useEditorState" editor/scaffolds/editor -g '*.{ts,tsx}'Repository: gridaco/grida
Length of output: 165
Add null guard for notification_respondent_email.
The type definition allows notification_respondent_email to be null (as seen in grida-forms-hosted/types.ts). Directly accessing properties on a potentially null value at lines 47–53 will crash the component. Other parts of the codebase (e.g., layout.tsx) already use defensive patterns with the nullish coalescing operator.
Defensive defaults
- const initial = form.notification_respondent_email;
+ const initial = form.notification_respondent_email ?? {
+ enabled: false,
+ from_name: null,
+ subject_template: null,
+ body_html_template: null,
+ reply_to: null,
+ };🤖 Prompt for AI Agents
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`
around lines 38 - 53, The code reads properties off
form.notification_respondent_email (assigned to initial) without guarding for
null; update the null guard so initial is ensured to be an object with safe
defaults (e.g. enabled: false, from_name: '', subject_template: '',
body_html_template: '', reply_to: '') before calling useForm. Replace the direct
reference to form.notification_respondent_email with a nullish-coalesced
fallback (or an explicit conditional) so useForm's defaultValues (enabled,
from_name, subject_template, body_html_template, reply_to) never access
properties on null; keep the same keys used in useForm for consistency.
| {body_html_template?.trim() ? ( | ||
| <div | ||
| // Admin-authored HTML preview. This is only rendered inside the editor. | ||
| dangerouslySetInnerHTML={{ | ||
| __html: body_html_template, | ||
| }} | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "DOMPurify|sanitizeHtml|sanitizeHTML|xss" editor -g '*.{ts,tsx}' | head -30Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# First, let's examine the target file to understand the source of body_html_template
fd "notification-respondent-email-preferences.tsx" editor -type fRepository: gridaco/grida
Length of output: 227
🏁 Script executed:
# Once we have the file, let's read it to understand context
cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | sed -n '1,50p'Repository: gridaco/grida
Length of output: 1909
🏁 Script executed:
# Continue reading the file to see lines 152-158 and surrounding context
cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | sed -n '30,100p'Repository: gridaco/grida
Length of output: 2645
🏁 Script executed:
# Read lines around 152-158 where the issue is
cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | sed -n '130,170p'Repository: gridaco/grida
Length of output: 1619
🏁 Script executed:
# Check if there are any existing sanitizers in the entire editor directory
rg "DOMPurify|sanitizeHtml|sanitizeHTML|xss|purify" editor --type ts --type tsx | head -20Repository: gridaco/grida
Length of output: 84
🏁 Script executed:
# Check package.json for dependencies that might include sanitizers
fd "package.json" editor -type f -exec cat {} \;Repository: gridaco/grida
Length of output: 227
🏁 Script executed:
# Search for existing sanitizers using correct syntax
rg "DOMPurify|sanitizeHtml|sanitizeHTML|xss|sanitize" editor --glob "*.ts" --glob "*.tsx" | head -20Repository: gridaco/grida
Length of output: 1851
🏁 Script executed:
# Check what EmailTemplateAuthoringKit is - might already include sanitization
rg -n "EmailTemplateAuthoringKit" editor --glob "*.ts" --glob "*.tsx" -A 5 -B 5Repository: gridaco/grida
Length of output: 4464
🏁 Script executed:
# Check the package.json in editor to see dependencies
cat editor/package.json | grep -A 50 '"dependencies"' | head -60Repository: gridaco/grida
Length of output: 1897
🏁 Script executed:
# Look for any sanitization in form data handling or API calls
rg -n "sanitize|purify|DOMPurify" . --glob "*.ts" --glob "*.tsx" | head -30Repository: gridaco/grida
Length of output: 2146
🏁 Script executed:
# Check API endpoint that handles notification_respondent_email save
rg -n "updateNotificationRespondentEmail\|notification_respondent_email" editor --glob "*.ts" --glob "*.tsx" | grep -i "api\|endpoint\|handler"Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# Search in the broader codebase for server-side sanitization of HTML templates
rg -n "notification_respondent_email" . --glob "*.ts" --glob "*.tsx" | head -20Repository: gridaco/grida
Length of output: 2316
🏁 Script executed:
# Check if DOMPurify is mentioned anywhere or if there's a sanitization strategy
rg -n "dompurify\|DOMPurify" . --glob "*.ts" --glob "*.tsx" --glob "*.json"Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# Check the API route that saves notification_respondent_email templates
cat -n editor/app/\(api\)/private/editor/settings/notification-respondent-email/route.ts | head -100Repository: gridaco/grida
Length of output: 1580
🏁 Script executed:
# Also check the types to understand the data structure
cat -n editor/grida-forms-hosted/types.ts | sed -n '30,50p'Repository: gridaco/grida
Length of output: 830
🏁 Script executed:
# Check if there's any sanitization in the hooks or form submission handler
cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks.ts | sed -n '50,80p'Repository: gridaco/grida
Length of output: 860
🏁 Script executed:
# Check how HTML is sanitized elsewhere in the codebase for comparison
rg -n "innerHTML\|dangerouslySetInnerHTML" editor --glob "*.ts" --glob "*.tsx" -B 2 -A 2 | head -50Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# Check if there's any validation/sanitization in form schema or types
rg -n "body_html_template" editor --glob "*.ts" --glob "*.tsx" | grep -i "schema\|validate\|sanitize"Repository: gridaco/grida
Length of output: 39
🏁 Script executed:
# Check the root package.json for any HTML sanitization dependencies
cat package.json | grep -A 100 "dependencies" | grep -i "dompurify\|sanitize\|xss\|html"Repository: gridaco/grida
Length of output: 39
Sanitize admin-authored HTML before rendering to prevent stored XSS.
dangerouslySetInnerHTML will execute scripts embedded in templates. Add DOMPurify to sanitize the HTML before injecting it, as templates are persisted in the database and reused in both editor previews and actual email notifications.
🛡️ Proposed sanitization
import React, { useMemo } from "react";
+import DOMPurify from "dompurify";dangerouslySetInnerHTML={{
- __html: body_html_template,
+ __html: DOMPurify.sanitize(body_html_template),
}}🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 154-154: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.3.13)
[error] 155-155: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`
around lines 152 - 158, Sanitize the admin-authored HTML before using
dangerouslySetInnerHTML: import and use DOMPurify to call
DOMPurify.sanitize(body_html_template) (optionally with a strict config like
FORBID_TAGS for scripts and event handlers) and pass the sanitized string to the
__html property instead of the raw body_html_template; update the JSX block that
currently uses dangerouslySetInnerHTML to use the sanitizedHtml variable (and
ensure this happens where body_html_template is defined/used in the component).
| export function renderRespondentEmail({ | ||
| form_title, | ||
| raw, | ||
| response_local_index, | ||
| response_local_id, | ||
| subject_template, | ||
| body_html_template, | ||
| }: { | ||
| form_title: string; | ||
| raw: Record<string, unknown>; | ||
| response_local_index: number; | ||
| response_local_id: string | null; | ||
| subject_template: string | null; | ||
| body_html_template: string; | ||
| }) { | ||
| const fields = stringifyFields(raw); | ||
|
|
||
| const contextVars = { | ||
| form_title, | ||
| fields, | ||
| response: { | ||
| short_id: response_local_id ?? null, | ||
| index: response_local_index, | ||
| idx: fmt_local_index(response_local_index), | ||
| }, | ||
| }; | ||
|
|
||
| const subjectSource = | ||
| subject_template?.trim() || `Thanks for your submission: {{form_title}}`; | ||
| const htmlSource = body_html_template.trim(); | ||
|
|
||
| return { | ||
| subject: render(subjectSource, contextVars as any), | ||
| html: render(htmlSource, contextVars as any), | ||
| context: contextVars, | ||
| }; |
There was a problem hiding this comment.
Guard against null body_html_template to prevent runtime crashes.
body_html_template is nullable in the editor state, but .trim() will throw if a null slips through. Consider allowing null and defaulting safely.
🛠️ Proposed fix
- body_html_template: string;
+ body_html_template: string | null;
...
- const htmlSource = body_html_template.trim();
+ const htmlSource = (body_html_template ?? "").trim();📝 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.
| export function renderRespondentEmail({ | |
| form_title, | |
| raw, | |
| response_local_index, | |
| response_local_id, | |
| subject_template, | |
| body_html_template, | |
| }: { | |
| form_title: string; | |
| raw: Record<string, unknown>; | |
| response_local_index: number; | |
| response_local_id: string | null; | |
| subject_template: string | null; | |
| body_html_template: string; | |
| }) { | |
| const fields = stringifyFields(raw); | |
| const contextVars = { | |
| form_title, | |
| fields, | |
| response: { | |
| short_id: response_local_id ?? null, | |
| index: response_local_index, | |
| idx: fmt_local_index(response_local_index), | |
| }, | |
| }; | |
| const subjectSource = | |
| subject_template?.trim() || `Thanks for your submission: {{form_title}}`; | |
| const htmlSource = body_html_template.trim(); | |
| return { | |
| subject: render(subjectSource, contextVars as any), | |
| html: render(htmlSource, contextVars as any), | |
| context: contextVars, | |
| }; | |
| export function renderRespondentEmail({ | |
| form_title, | |
| raw, | |
| response_local_index, | |
| response_local_id, | |
| subject_template, | |
| body_html_template, | |
| }: { | |
| form_title: string; | |
| raw: Record<string, unknown>; | |
| response_local_index: number; | |
| response_local_id: string | null; | |
| subject_template: string | null; | |
| body_html_template: string | null; | |
| }) { | |
| const fields = stringifyFields(raw); | |
| const contextVars = { | |
| form_title, | |
| fields, | |
| response: { | |
| short_id: response_local_id ?? null, | |
| index: response_local_index, | |
| idx: fmt_local_index(response_local_index), | |
| }, | |
| }; | |
| const subjectSource = | |
| subject_template?.trim() || `Thanks for your submission: {{form_title}}`; | |
| const htmlSource = (body_html_template ?? "").trim(); | |
| return { | |
| subject: render(subjectSource, contextVars as any), | |
| html: render(htmlSource, contextVars as any), | |
| context: contextVars, | |
| }; | |
| } |
🤖 Prompt for AI Agents
In `@editor/services/form/respondent-email.ts` around lines 25 - 60, The function
renderRespondentEmail currently calls body_html_template.trim() but
body_html_template can be null; change the htmlSource assignment in
renderRespondentEmail to safely handle null/undefined (e.g., coalesce
body_html_template to an empty string or a safe default before calling trim) so
trim never runs on null, and ensure render(htmlSource, ...) receives that safe
string; update any type annotations for body_html_template if needed to reflect
it may be nullable.
| ALTER TABLE grida_forms.form | ||
| DROP CONSTRAINT IF EXISTS form_notification_respondent_email_to_attribute_id_fkey, | ||
| DROP COLUMN IF EXISTS notification_respondent_email_enabled, | ||
| DROP COLUMN IF EXISTS notification_respondent_email_to_attribute_id, | ||
| DROP COLUMN IF EXISTS notification_respondent_email_subject_template, | ||
| DROP COLUMN IF EXISTS notification_respondent_email_body_html_template, | ||
| DROP COLUMN IF EXISTS notification_respondent_email_reply_to, | ||
| ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb; |
There was a problem hiding this comment.
Prevent data loss when consolidating columns into JSONB.
Dropping the existing respondent email columns before migrating their values will wipe any already-configured templates. Please backfill into the new JSONB column first (or explicitly document that this data loss is intended).
🔧 Suggested migration flow (backfill before drop)
-ALTER TABLE grida_forms.form
- DROP CONSTRAINT IF EXISTS form_notification_respondent_email_to_attribute_id_fkey,
- DROP COLUMN IF EXISTS notification_respondent_email_enabled,
- DROP COLUMN IF EXISTS notification_respondent_email_to_attribute_id,
- DROP COLUMN IF EXISTS notification_respondent_email_subject_template,
- DROP COLUMN IF EXISTS notification_respondent_email_body_html_template,
- DROP COLUMN IF EXISTS notification_respondent_email_reply_to,
- ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb;
+ALTER TABLE grida_forms.form
+ ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb;
+
+UPDATE grida_forms.form
+SET notification_respondent_email = jsonb_build_object(
+ 'enabled', notification_respondent_email_enabled,
+ 'subject_template', notification_respondent_email_subject_template,
+ 'body_html_template', notification_respondent_email_body_html_template,
+ 'reply_to', notification_respondent_email_reply_to
+)
+WHERE notification_respondent_email IS NULL;
+
+ALTER TABLE grida_forms.form
+ ALTER COLUMN notification_respondent_email SET DEFAULT '{}'::jsonb,
+ ALTER COLUMN notification_respondent_email SET NOT NULL,
+ DROP CONSTRAINT IF EXISTS form_notification_respondent_email_to_attribute_id_fkey,
+ DROP COLUMN IF EXISTS notification_respondent_email_enabled,
+ DROP COLUMN IF EXISTS notification_respondent_email_to_attribute_id,
+ DROP COLUMN IF EXISTS notification_respondent_email_subject_template,
+ DROP COLUMN IF EXISTS notification_respondent_email_body_html_template,
+ DROP COLUMN IF EXISTS notification_respondent_email_reply_to;📝 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.
| ALTER TABLE grida_forms.form | |
| DROP CONSTRAINT IF EXISTS form_notification_respondent_email_to_attribute_id_fkey, | |
| DROP COLUMN IF EXISTS notification_respondent_email_enabled, | |
| DROP COLUMN IF EXISTS notification_respondent_email_to_attribute_id, | |
| DROP COLUMN IF EXISTS notification_respondent_email_subject_template, | |
| DROP COLUMN IF EXISTS notification_respondent_email_body_html_template, | |
| DROP COLUMN IF EXISTS notification_respondent_email_reply_to, | |
| ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb; | |
| ALTER TABLE grida_forms.form | |
| ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb; | |
| UPDATE grida_forms.form | |
| SET notification_respondent_email = jsonb_build_object( | |
| 'enabled', notification_respondent_email_enabled, | |
| 'subject_template', notification_respondent_email_subject_template, | |
| 'body_html_template', notification_respondent_email_body_html_template, | |
| 'reply_to', notification_respondent_email_reply_to | |
| ) | |
| WHERE notification_respondent_email IS NULL; | |
| ALTER TABLE grida_forms.form | |
| ALTER COLUMN notification_respondent_email SET DEFAULT '{}'::jsonb, | |
| ALTER COLUMN notification_respondent_email SET NOT NULL, | |
| DROP CONSTRAINT IF EXISTS form_notification_respondent_email_to_attribute_id_fkey, | |
| DROP COLUMN IF EXISTS notification_respondent_email_enabled, | |
| DROP COLUMN IF EXISTS notification_respondent_email_to_attribute_id, | |
| DROP COLUMN IF EXISTS notification_respondent_email_subject_template, | |
| DROP COLUMN IF EXISTS notification_respondent_email_body_html_template, | |
| DROP COLUMN IF EXISTS notification_respondent_email_reply_to; |
🤖 Prompt for AI Agents
In `@supabase/migrations/20260204000000_grida_forms_respondent_email.sql` around
lines 5 - 12, The migration currently drops respondent-email columns before
consolidating them, which will lose data; instead, first ADD the new
notification_respondent_email jsonb column (notification_respondent_email) if
missing, then run an UPDATE on grida_forms.form to build a JSON object from the
existing columns (notification_respondent_email_enabled,
notification_respondent_email_to_attribute_id,
notification_respondent_email_subject_template,
notification_respondent_email_body_html_template,
notification_respondent_email_reply_to) taking care to handle NULLs and use the
existing default, and only after verifying/backfilling run DROP CONSTRAINT
form_notification_respondent_email_to_attribute_id_fkey and DROP COLUMN for the
old columns; ensure the UPDATE merges or sets keys consistently (enabled,
to_attribute_id, subject_template, body_html_template, reply_to) so existing
templates are preserved.
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In
`@editor/app/`(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts:
- Around line 11-134: The endpoint POST handler uses privileged service_role and
resend.emails.send and is publicly reachable; add an authentication guard that
verifies a shared secret/header before proceeding (e.g., read a configured
SECRET from env and compare it to a header like x-hook-secret at the start of
POST), return 401/403 when missing or invalid, and only then continue to query
with service_role and call resend.emails.send; update function POST to perform
this check early and abort before any DB or email calls if the secret fails.
In `@editor/app/`(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx:
- Around line 83-142: The "Coming soon" SMS block is still keyboard-focusable
because pointer-events-none doesn't stop tabbing; update the disabled UI so
interactive elements are actually inert: set the Select component
(name="originator") and its interactive parts (SelectTrigger, SelectItem,
SelectValue, SelectContent) to a disabled state (or pass disabled prop), ensure
any DialogTrigger/Dialog-like controls and the TestSMS component receive a
disabled prop or are not rendered when PreferenceBox is disabled, and add
aria-disabled="true" and tabIndex={-1} (or use inert attribute) on the outer
wrapper around MessageAppFrame and the form controls so the entire block is
non-interactive and removed from keyboard focus.
- Around line 111-114: Update the SMS preview copy in the messages array inside
page.tsx: change the string value for the object with role "incoming" from
"Event is openning soon. Register now!" to "Event is opening soon. Register
now!" so the typo in the message property is corrected.
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`:
- Around line 152-158: The admin HTML preview currently injects
body_html_template directly via dangerouslySetInnerHTML (refer to
body_html_template usage in the render block) — sanitize it first (e.g., import
DOMPurify and call DOMPurify.sanitize(body_html_template || '') or another
trusted sanitizer) and use the sanitized result in dangerouslySetInnerHTML;
ensure the sanitizer is applied once before rendering and handle empty/null
templates to avoid rendering undefined.
In `@editor/services/form/respondent-email.ts`:
- Around line 25-55: The parameter type for body_html_template in
renderRespondentEmail is incorrectly declared as string; change its type to
string | null and handle the null case consistently (similar to
subject_template) by using optional chaining and a sensible default—e.g.,
compute htmlSource via body_html_template?.trim() ?? '' or another appropriate
fallback—to avoid runtime errors when callers pass null; update the function
signature and the htmlSource assignment in renderRespondentEmail accordingly.
🧹 Nitpick comments (3)
editor/kits/AGENTS.md (1)
54-58: Consider addingemail-template-authoringto the kits table.The table currently only lists
minimal-tiptap, but this PR introduces theemail-template-authoringkit. Consider adding it to keep the documentation current.📝 Suggested addition
| kit | description | entry | | ---------------- | ------------------------------------------------ | ----------------------- | | `minimal-tiptap` | Opinionated rich-text editor kit (Tiptap-based). | `@/kits/minimal-tiptap` | +| `email-template-authoring` | Email-client-style template authoring UI. | `@/kits/email-template-authoring` |editor/app/(api)/(public)/v1/submit/[id]/route.ts (1)
1117-1126: Unhandled promise rejection:try-catchwon't catch async errors.The
OnSubmit.notification_respondent_email()call is not awaited, so thetry-catchblock only catches synchronous errors during the initial function invocation. Any errors from thefetchpromise (network failures, HTTP errors) will result in unhandled promise rejections.This matches the pattern used by
clearsessionandpostindexingabove, but since the TODO mentions moving to PGMQ/jobs, consider either:
- Adding
.catch()to explicitly handle the promise rejection- Awaiting the call if delivery confirmation matters
🔧 Proposed fix to handle promise rejection
// respondent email hook (best-effort) // TODO: move to PGMQ/jobs for retryable delivery - try { - OnSubmit.notification_respondent_email({ - form_id, - response_id: response_reference_obj.id, - }); - } catch (e) { - console.error("submit/err/hooks/notification-respondent-email", e); - } + OnSubmit.notification_respondent_email({ + form_id, + response_id: response_reference_obj.id, + }).catch((e) => { + console.error("submit/err/hooks/notification-respondent-email", e); + });supabase/migrations/20260204000000_grida_forms_respondent_email.sql (1)
12-12: Consider adding a CHECK constraint or JSON schema validation.The new
notification_respondent_emailJSONB column accepts any JSON structure. Consider adding a CHECK constraint to validate the expected shape (e.g.,enabledis boolean) to prevent invalid data at the database level.🔧 Proposed constraint for type safety
- ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb; + ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb + CONSTRAINT notification_respondent_email_shape CHECK ( + notification_respondent_email IS NULL OR ( + (notification_respondent_email->>'enabled' IS NULL OR jsonb_typeof(notification_respondent_email->'enabled') = 'boolean') + ) + );
editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts
Show resolved
Hide resolved
| {body_html_template?.trim() ? ( | ||
| <div | ||
| // Admin-authored HTML preview. This is only rendered inside the editor. | ||
| dangerouslySetInnerHTML={{ | ||
| __html: body_html_template, | ||
| }} | ||
| /> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | head -200Repository: gridaco/grida
Length of output: 7815
🏁 Script executed:
rg "DOMPurify" --type ts --type tsxRepository: gridaco/grida
Length of output: 84
🏁 Script executed:
rg "DOMPurify|sanitize|xss" --type ts -iRepository: gridaco/grida
Length of output: 2472
🏁 Script executed:
rg "body_html_template" editor/scaffolds/settings/ -B 3 -A 3Repository: gridaco/grida
Length of output: 6016
🏁 Script executed:
rg "notification_respondent_email" -B 2 -A 2 | head -60Repository: gridaco/grida
Length of output: 5280
🏁 Script executed:
rg "PrivateEditorApi.Settings.updateNotificationRespondentEmail" -B 5 -A 10Repository: gridaco/grida
Length of output: 1913
🏁 Script executed:
find editor -name "*.ts" -o -name "*.tsx" | xargs rg -l "PrivateEditorApi" | head -5Repository: gridaco/grida
Length of output: 316
🏁 Script executed:
rg "PrivateEditorApi" editor/lib/ -B 2 -A 2 | head -40Repository: gridaco/grida
Length of output: 351
🏁 Script executed:
rg "updateNotificationRespondentEmail" editor/lib/private/ -B 3 -A 15Repository: gridaco/grida
Length of output: 1122
🏁 Script executed:
find editor -path "*private/editor/settings*" -o -path "*api*" | grep -i notificationRepository: gridaco/grida
Length of output: 370
🏁 Script executed:
cat editor/app/\(api\)/private/editor/settings/notification-respondent-email/route.tsRepository: gridaco/grida
Length of output: 1237
🏁 Script executed:
cat editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.tsRepository: gridaco/grida
Length of output: 3833
🏁 Script executed:
rg "renderRespondentEmail" editor/ -B 2 -A 15Repository: gridaco/grida
Length of output: 7809
🏁 Script executed:
cat editor/services/form/respondent-email.tsRepository: gridaco/grida
Length of output: 234
🏁 Script executed:
head -100 editor/services/form/respondent-email.tsRepository: gridaco/grida
Length of output: 234
🏁 Script executed:
cat editor/services/form/respondent-email.ts | head -80Repository: gridaco/grida
Length of output: 234
Sanitize HTML before injecting into the editor preview.
dangerouslySetInnerHTML at lines 152-158 renders admin-provided HTML template directly without sanitization. Even in admin-only contexts, unsanitized HTML injection creates an XSS vector if malicious or compromised templates are rendered. Sanitize the template using a library like DOMPurify before rendering.
🛡️ Suggested fix (using DOMPurify as an example)
-import React, { useMemo } from "react";
+import React, { useMemo } from "react";
+import DOMPurify from "dompurify";
@@
- dangerouslySetInnerHTML={{
- __html: body_html_template,
- }}
+ dangerouslySetInnerHTML={{
+ __html: DOMPurify.sanitize(body_html_template ?? ""),
+ }}📝 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.
| {body_html_template?.trim() ? ( | |
| <div | |
| // Admin-authored HTML preview. This is only rendered inside the editor. | |
| dangerouslySetInnerHTML={{ | |
| __html: body_html_template, | |
| }} | |
| /> | |
| import React, { useMemo } from "react"; | |
| import DOMPurify from "dompurify"; | |
| {body_html_template?.trim() ? ( | |
| <div | |
| // Admin-authored HTML preview. This is only rendered inside the editor. | |
| dangerouslySetInnerHTML={{ | |
| __html: DOMPurify.sanitize(body_html_template ?? ""), | |
| }} | |
| /> |
🧰 Tools
🪛 ast-grep (0.40.5)
[warning] 154-154: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.3.13)
[error] 155-155: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In `@editor/scaffolds/settings/notification-respondent-email-preferences.tsx`
around lines 152 - 158, The admin HTML preview currently injects
body_html_template directly via dangerouslySetInnerHTML (refer to
body_html_template usage in the render block) — sanitize it first (e.g., import
DOMPurify and call DOMPurify.sanitize(body_html_template || '') or another
trusted sanitizer) and use the sanitized result in dangerouslySetInnerHTML;
ensure the sanitizer is applied once before rendering and handle empty/null
templates to avoid rendering undefined.
| export function renderRespondentEmail({ | ||
| form_title, | ||
| raw, | ||
| response_local_index, | ||
| response_local_id, | ||
| subject_template, | ||
| body_html_template, | ||
| }: { | ||
| form_title: string; | ||
| raw: Record<string, unknown>; | ||
| response_local_index: number; | ||
| response_local_id: string | null; | ||
| subject_template: string | null; | ||
| body_html_template: string; | ||
| }) { | ||
| const fields = stringifyFields(raw); | ||
|
|
||
| const contextVars = { | ||
| form_title, | ||
| fields, | ||
| response: { | ||
| short_id: response_local_id ?? null, | ||
| index: response_local_index, | ||
| idx: fmt_local_index(response_local_index), | ||
| }, | ||
| }; | ||
|
|
||
| const subjectSource = | ||
| subject_template?.trim() || `Thanks for your submission: {{form_title}}`; | ||
| const htmlSource = body_html_template.trim(); | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n editor/services/form/respondent-email.tsRepository: gridaco/grida
Length of output: 1995
🏁 Script executed:
rg -n "renderRespondentEmail" --type ts --type tsx -A 3 -B 3Repository: gridaco/grida
Length of output: 84
🏁 Script executed:
rg -n "body_html_template" --type ts --type tsx -B 2 -A 2Repository: gridaco/grida
Length of output: 84
🏁 Script executed:
rg -n "renderRespondentEmail" -A 5 -B 2Repository: gridaco/grida
Length of output: 3861
🏁 Script executed:
rg -n "body_html_template" -B 3 -A 3Repository: gridaco/grida
Length of output: 15524
🏁 Script executed:
# Verify the call site more completely to confirm htmlSource handling
head -120 editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts | tail -30Repository: gridaco/grida
Length of output: 920
Fix type signature mismatch for body_html_template to prevent potential runtime errors.
The function parameter is typed as string but all configuration types throughout the codebase define body_html_template as string | null. This creates a type contract violation—while the current production call site validates before passing a non-null value, the type signature doesn't enforce this and invites bugs if new callers skip validation. Additionally, subject_template is correctly typed as string | null and safely handled with ?.trim(), making this inconsistent. Update the parameter type and safely handle the null case:
Suggested fix
- body_html_template: string;
+ body_html_template: string | null;
@@
- const htmlSource = body_html_template.trim();
+ const htmlSource = (body_html_template ?? "").trim();🤖 Prompt for AI Agents
In `@editor/services/form/respondent-email.ts` around lines 25 - 55, The parameter
type for body_html_template in renderRespondentEmail is incorrectly declared as
string; change its type to string | null and handle the null case consistently
(similar to subject_template) by using optional chaining and a sensible
default—e.g., compute htmlSource via body_html_template?.trim() ?? '' or another
appropriate fallback—to avoid runtime errors when callers pass null; update the
function signature and the htmlSource assignment in renderRespondentEmail
accordingly.
…for email-client-style template authoring UI.
Introduce a new type for `notification_respondent_email` in the database schema, allowing for a structured configuration with optional fields. Update related API and UI components to utilize this new type, simplifying the handling of email notification settings in forms. This change enhances type safety and improves code readability across the application.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@editor/.env.example`:
- Line 50: The placeholder value for GRIDA_S2S_PRIVATE_API_KEY includes double
quotes which will become part of the env value; update the environment example
so GRIDA_S2S_PRIVATE_API_KEY is set without surrounding quotes (e.g.,
GRIDA_S2S_PRIVATE_API_KEY=replace-with-a-long-random-secret) to match the style
used for other keys and avoid users accidentally including quotes when they
replace the placeholder.
🧹 Nitpick comments (3)
editor/.env.example (2)
53-53: Remove quotes and consider alphabetical ordering.Similar to line 50, the double quotes around the value should be removed. Additionally, for consistency with dotenv linting conventions,
GRIDA_INTERNAL_PROXY_TOKENcould be placed beforeGRIDA_S2S_PRIVATE_API_KEY(alphabetical order).🔧 Proposed fix
+# internal (proxy -> internal api auth) +# used to authenticate requests from `proxy.ts` to `/internal/resolve-host` +GRIDA_INTERNAL_PROXY_TOKEN=replace-with-a-long-random-secret # s2s private api key (intent key) # used to guard public endpoints that perform privileged actions server-side # must match request header: `x-grida-s2s-key` -GRIDA_S2S_PRIVATE_API_KEY="replace-with-a-long-random-secret" -# internal (proxy -> internal api auth) -# used to authenticate requests from `proxy.ts` to `/internal/resolve-host` -GRIDA_INTERNAL_PROXY_TOKEN="replace-with-a-long-random-secret" +GRIDA_S2S_PRIVATE_API_KEY=replace-with-a-long-random-secret
68-68: Remove extra blank line.Linter flagged an extra blank line here. Consider removing it for consistency.
🧹 Proposed fix
- - +editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts (1)
1-7: Remove unused import.The
notFoundimport fromnext/navigationshould be removed if you switch to JSON responses as recommended.import { NextRequest, NextResponse } from "next/server"; -import assert from "assert"; -import { notFound } from "next/navigation"; import validator from "validator";
Enhance the email notification API by including the S2S private API key in the request headers if it is defined. This change improves security and allows for better integration with the Grida Forms notification system.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 3 potential issues.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.
This PR is being reviewed by Cursor Bugbot
Details
You are on the Bugbot Free tier. On this plan, Bugbot will review limited PRs each billing cycle.
To receive Bugbot reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.
| }); | ||
| } catch (e) { | ||
| console.error("submit/err/hooks/notification-respondent-email", e); | ||
| } |
There was a problem hiding this comment.
Async hook called without await breaks error handling
Medium Severity
The OnSubmit.notification_respondent_email() function returns a Promise (from fetch()), but it's called without await. This means the try-catch block will never catch errors from the async operation — network failures, server errors, or authentication issues will result in unhandled promise rejections instead of being logged. The error handling code (console.error) is effectively dead code for this hook.
| subject: render(subjectSource, contextVars as any), | ||
| html: render(htmlSource, contextVars as any), | ||
| context: contextVars, | ||
| }; |
There was a problem hiding this comment.
| InputGroupTextarea, | ||
| } from "@/components/ui/input-group"; | ||
|
|
||
| export type FieldState = "disabled" | "off" | "on"; |
There was a problem hiding this comment.


day-317-grida-forms-custom-repondent-email.mp4
New Features
Documentation
Tests