Skip to content

Grida Forms - Respondent Email Notifications#522

Merged
softmarshmallow merged 10 commits intomainfrom
canary
Feb 5, 2026
Merged

Grida Forms - Respondent Email Notifications#522
softmarshmallow merged 10 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Member

@softmarshmallow softmarshmallow commented Feb 4, 2026

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

    • Per-form respondent email notifications: enable/disable and send customizable emails to respondents after submission.
    • Email template authoring & preview: configure From name, Reply‑To, Subject, and HTML body with template variables and live preview.
    • Editor settings & UI: new preferences panel in the editor to edit and save notification templates; best‑effort background delivery after submit.
  • Documentation

    • Added detailed guide on setup, templating, enabling notifications, verified‑email prerequisites, and troubleshooting.
  • Tests

    • Added unit tests for email rendering and field serialization.

Overview
Adds per-form respondent email notifications by introducing a new notification_respondent_email JSONB config on grida_forms.form (with DB JSON-schema enforcement) and wiring it through Supabase types and editor state.

On submission, the public /v1/submit/:id flow now best-effort invokes a new guarded hook endpoint that loads the form config + response, requires a verified CIAM customer email, renders Handlebars templates, and sends via Resend (tagged for tracking). The editor gains a new Channels preferences panel (plus a reusable EmailTemplateAuthoringKit) and a private settings API to enable/disable and update templates; documentation is added for setup, templating variables, and troubleshooting.

Written by Cursor Bugbot for commit 4244771. This will update automatically on new commits. Configure here.

…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.
@vercel
Copy link

vercel bot commented Feb 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Feb 5, 2026 11:30am
grida Ready Ready Preview, Comment Feb 5, 2026 11:30am
5 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored Feb 5, 2026 11:30am
legacy Ignored Ignored Feb 5, 2026 11:30am
backgrounds Skipped Skipped Feb 5, 2026 11:30am
blog Skipped Skipped Feb 5, 2026 11:30am
viewer Skipped Skipped Feb 5, 2026 11:30am

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

Walkthrough

Adds 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

Cohort / File(s) Summary
Database & Types
supabase/migrations/20260204000000_grida_forms_respondent_email.sql, database/database-generated.types.ts, database/database.types.ts
Add notification_respondent_email JSONB column with schema check; introduce FormNotificationRespondentEmailConfig and update form Row/Insert/Update types.
Submit hook & public routes
editor/app/(api)/(public)/v1/submit/[id]/hooks.ts, editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts, editor/app/(api)/(public)/v1/submit/[id]/route.ts
New POST hook and route to send respondent emails; integrated as a best-effort call from submit flow; validates config, customer/email, renders templates, and sends via Resend.
Private settings API & client
editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts, editor/lib/private/index.ts, editor/types/private/api.ts
New private POST endpoint and client helper to update per-form notification settings; added UpdateFormNotificationRespondentEmailRequest type.
Editor types, init, state, actions, reducer
editor/grida-forms-hosted/types.ts, editor/scaffolds/editor/state.ts, editor/scaffolds/editor/init.ts, editor/scaffolds/editor/action.ts, editor/scaffolds/editor/reducer.ts, editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx
Expose notification_respondent_email in Form type and editor init/payload; add editor state field, action type, and reducer case to merge preferences.
Editor UI & Kits
editor/kits/email-template-authoring/index.tsx, editor/kits/email-template-authoring/README.md, editor/scaffolds/settings/notification-respondent-email-preferences.tsx, editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx
Add EmailTemplateAuthoringKit and NotificationRespondentEmailPreferences UI; integrate preference UI into workbench/connect and adjust SMS/email placeholders.
Email rendering & tests
editor/services/form/respondent-email.ts, editor/services/form/respondent-email.test.ts
Add utilities toStringValue, stringifyFields, and renderRespondentEmail to build context and render subject/body templates; unit tests added.
Docs & Guides
docs/forms/respondent-email-notifications.md, docs/AGENTS.md, editor/components/AGENTS.md, editor/kits/AGENTS.md
Add documentation for respondent-email notifications and component/kit guidelines; update docs frontmatter/index.
Misc / Env / Sidebar
editor/.env.example, editor/scaffolds/sidebar/sidebar-mode-connect.tsx
Add GRIDA_S2S_PRIVATE_API_KEY hint to env example; replace disabled Domain item with an active external link in the sidebar.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested labels

migration, documentation

"I'm a rabbit with a tiny mailbag,
hopping templates and CIAM flag.
From subject line to HTML art,
respondents get the message—smart.
Hooray for forms that send from the heart!" 🐇✉️

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately and concisely summarizes the main feature being added: respondent email notifications for Grida Forms across the entire changeset.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch canary

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

❤️ Share

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

…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.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +1119 to +1123
try {
OnSubmit.notification_respondent_email({
form_id,
response_id: response_reference_obj.id,
});

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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-none blocks 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.

Comment on lines 11 to 134
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 });
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/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.ts

Repository: 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 -20

Repository: 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.

Comment on lines +17 to +38
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();
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts

Repository: 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:

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:


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 }).

Comment on lines +1117 to +1124
// 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) {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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 -20

Repository: 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 5

Repository: gridaco/grida

Length of output: 84


🏁 Script executed:

find editor/app/\(api\)/\(public\)/v1/submit/\[id\]/ -name "hooks*" -type f

Repository: 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.ts

Repository: 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.

Suggested change
// 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.

Comment on lines +26 to +35
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,
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +38 to +53
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,
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | head -60

Repository: gridaco/grida

Length of output: 2417


🏁 Script executed:

rg -n "notification_respondent_email" editor -g '*.{ts,tsx}' -B 2 -A 2

Repository: gridaco/grida

Length of output: 9257


🏁 Script executed:

rg -n "type FormValues\|interface FormValues" editor/scaffolds/settings/notification-respondent-email-preferences.tsx -A 10

Repository: gridaco/grida

Length of output: 39


🏁 Script executed:

rg -n "notification_respondent_email" editor/scaffolds/editor/state.ts -B 2 -A 8

Repository: 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 8

Repository: gridaco/grida

Length of output: 373


🏁 Script executed:

rg -n "const form = " editor/scaffolds/settings/notification-respondent-email-preferences.tsx -B 5 -A 5

Repository: gridaco/grida

Length of output: 39


🏁 Script executed:

rg -n "useEditorState" editor/scaffolds/editor/index.ts -A 3

Repository: 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.

Comment on lines +152 to +158
{body_html_template?.trim() ? (
<div
// Admin-authored HTML preview. This is only rendered inside the editor.
dangerouslySetInnerHTML={{
__html: body_html_template,
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "DOMPurify|sanitizeHtml|sanitizeHTML|xss" editor -g '*.{ts,tsx}' | head -30

Repository: 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 f

Repository: 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 -20

Repository: 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 -20

Repository: 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 5

Repository: 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 -60

Repository: 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 -30

Repository: 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 -20

Repository: 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 -100

Repository: 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 -50

Repository: 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).

Comment on lines +25 to +60
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,
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines 5 to 12
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;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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 adding email-template-authoring to the kits table.

The table currently only lists minimal-tiptap, but this PR introduces the email-template-authoring kit. 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-catch won't catch async errors.

The OnSubmit.notification_respondent_email() call is not awaited, so the try-catch block only catches synchronous errors during the initial function invocation. Any errors from the fetch promise (network failures, HTTP errors) will result in unhandled promise rejections.

This matches the pattern used by clearsession and postindexing above, but since the TODO mentions moving to PGMQ/jobs, consider either:

  1. Adding .catch() to explicitly handle the promise rejection
  2. 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_email JSONB column accepts any JSON structure. Consider adding a CHECK constraint to validate the expected shape (e.g., enabled is 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')
+      )
+    );

Comment on lines +152 to +158
{body_html_template?.trim() ? (
<div
// Admin-authored HTML preview. This is only rendered inside the editor.
dangerouslySetInnerHTML={{
__html: body_html_template,
}}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n editor/scaffolds/settings/notification-respondent-email-preferences.tsx | head -200

Repository: gridaco/grida

Length of output: 7815


🏁 Script executed:

rg "DOMPurify" --type ts --type tsx

Repository: gridaco/grida

Length of output: 84


🏁 Script executed:

rg "DOMPurify|sanitize|xss" --type ts -i

Repository: gridaco/grida

Length of output: 2472


🏁 Script executed:

rg "body_html_template" editor/scaffolds/settings/ -B 3 -A 3

Repository: gridaco/grida

Length of output: 6016


🏁 Script executed:

rg "notification_respondent_email" -B 2 -A 2 | head -60

Repository: gridaco/grida

Length of output: 5280


🏁 Script executed:

rg "PrivateEditorApi.Settings.updateNotificationRespondentEmail" -B 5 -A 10

Repository: gridaco/grida

Length of output: 1913


🏁 Script executed:

find editor -name "*.ts" -o -name "*.tsx" | xargs rg -l "PrivateEditorApi" | head -5

Repository: gridaco/grida

Length of output: 316


🏁 Script executed:

rg "PrivateEditorApi" editor/lib/ -B 2 -A 2 | head -40

Repository: gridaco/grida

Length of output: 351


🏁 Script executed:

rg "updateNotificationRespondentEmail" editor/lib/private/ -B 3 -A 15

Repository: gridaco/grida

Length of output: 1122


🏁 Script executed:

find editor -path "*private/editor/settings*" -o -path "*api*" | grep -i notification

Repository: gridaco/grida

Length of output: 370


🏁 Script executed:

cat editor/app/\(api\)/private/editor/settings/notification-respondent-email/route.ts

Repository: gridaco/grida

Length of output: 1237


🏁 Script executed:

cat editor/app/\(api\)/\(public\)/v1/submit/\[id\]/hooks/notification-respondent-email/route.ts

Repository: gridaco/grida

Length of output: 3833


🏁 Script executed:

rg "renderRespondentEmail" editor/ -B 2 -A 15

Repository: gridaco/grida

Length of output: 7809


🏁 Script executed:

cat editor/services/form/respondent-email.ts

Repository: gridaco/grida

Length of output: 234


🏁 Script executed:

head -100 editor/services/form/respondent-email.ts

Repository: gridaco/grida

Length of output: 234


🏁 Script executed:

cat editor/services/form/respondent-email.ts | head -80

Repository: 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.

Suggested change
{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.

Comment on lines +25 to +55
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();

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n editor/services/form/respondent-email.ts

Repository: gridaco/grida

Length of output: 1995


🏁 Script executed:

rg -n "renderRespondentEmail" --type ts --type tsx -A 3 -B 3

Repository: gridaco/grida

Length of output: 84


🏁 Script executed:

rg -n "body_html_template" --type ts --type tsx -B 2 -A 2

Repository: gridaco/grida

Length of output: 84


🏁 Script executed:

rg -n "renderRespondentEmail" -A 5 -B 2

Repository: gridaco/grida

Length of output: 3861


🏁 Script executed:

rg -n "body_html_template" -B 3 -A 3

Repository: 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 -30

Repository: 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.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 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_TOKEN could be placed before GRIDA_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 notFound import from next/navigation should 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";

@vercel vercel bot temporarily deployed to Preview – blog February 4, 2026 15:58 Inactive
@vercel vercel bot temporarily deployed to Preview – backgrounds February 4, 2026 15:58 Inactive
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.
@vercel vercel bot temporarily deployed to Preview – viewer February 5, 2026 11:27 Inactive
@vercel vercel bot temporarily deployed to Preview – backgrounds February 5, 2026 11:27 Inactive
@vercel vercel bot temporarily deployed to Preview – blog February 5, 2026 11:27 Inactive
@softmarshmallow softmarshmallow merged commit 68f815d into main Feb 5, 2026
12 checks passed
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

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);
}
Copy link

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

subject: render(subjectSource, contextVars as any),
html: render(htmlSource, contextVars as any),
context: contextVars,
};
Copy link

Choose a reason for hiding this comment

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

Unused context return property in render function

Low Severity

renderRespondentEmail returns a context property (line 59) that is never used by any caller. Both the route handler and test file only destructure { subject, html }. The unused context return value should be removed.

Fix in Cursor Fix in Web

InputGroupTextarea,
} from "@/components/ui/input-group";

export type FieldState = "disabled" | "off" | "on";
Copy link

Choose a reason for hiding this comment

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

Unused FieldState type export

Low Severity

The FieldState type is exported but never used. The ControlledField<T> discriminated union (lines 13-27) uses literal string types "disabled", "off", "on" directly rather than referencing FieldState. No external file imports this type.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant