From 4c57b44e8b53e1d9bc4206338110208330f1ebf3 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 22:52:57 +0900 Subject: [PATCH 01/10] Add documentation for `editor/components` and `editor/kits` directories, 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. --- editor/components/AGENTS.md | 46 +++++++++++++++++++ editor/kits/AGENTS.md | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 editor/components/AGENTS.md create mode 100644 editor/kits/AGENTS.md diff --git a/editor/components/AGENTS.md b/editor/components/AGENTS.md new file mode 100644 index 000000000..757a5d002 --- /dev/null +++ b/editor/components/AGENTS.md @@ -0,0 +1,46 @@ +# `editor/components` + +`/editor/components` contains **reusable UI building blocks**. + +The intended shape of this directory is: + +- **Unopinionated**: primitives with a clear role; avoid app-feature coupling. +- **Reusable anywhere**: components should be safe to use across routes/features. +- **Override-friendly**: default styles should look good, but consumers must be able to override easily (typically via `className`, composition, and prop-driven variants). + +If you find yourself adding heavy state machines, feature workflows, or global/editor state bindings, it likely belongs in `kits/` or `scaffolds/` instead. + +## Hard constraints (please follow) + +- **No route coupling**: do not import from specific Next.js route segments under `app/` (e.g. `app/(workbench)/...`). +- **Avoid global state coupling**: prefer props and local state; do not require editor/workbench global stores just to render. +- **Composable styling**: prefer `className` + merge helper (e.g. `cn(...)`) and avoid “closed” styling that can’t be overridden. +- **Small surface area**: keep components narrowly-scoped; split when a component becomes a mini-feature. + +## Directory map (highlighted) + +| directory | role | opinionation | notes | +| ------------------------- | ---------------------------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| `components/ui/` | **Base primitives** (Shadcn-style) used across the app. | low | Treated as our “default primitive set”. Prefer using **as-is** (avoid local forks/overrides unless necessary). | +| `components/ui-editor/` | Editor-leaning primitives (generally more condensed/special) for editor-ish UI/UX. | medium | A parallel set to `ui/` when the editor needs different density/interaction defaults. | +| `components/ui-forms/` | Forms-specific UI primitives. | medium | Opinionated toward predictable forms behavior/UX. | +| `components/ui2/` | Preview-only UI primitives for the visual builder. | medium | Dedicated to preview constraints; e.g. relative-position overlays/dialog mechanics. | +| `components/ai-elements/` | AI UI primitives imported from a Shadcn registry-style source. | medium | Keep the public surface small and override-friendly; treat similarly to registry components (avoid deep local redesign unless needed). | + +## How to choose where a component goes + +| if your component is… | put it in… | +| -------------------------------------------------------------------------- | ----------------------- | +| a basic primitive (button, input, popover, dialog, etc.) | `components/ui/` | +| a primitive but optimized for editor density / special editor interactions | `components/ui-editor/` | +| a primitive meant for forms-specific UX | `components/ui-forms/` | +| a primitive that must work inside preview/embedded rendering constraints | `components/ui2/` | +| a higher-level widget with internal state but used broadly | `kits/` | +| bound to global editor/workbench state, or a feature assembly | `scaffolds/` | + +## Authoring guidelines + +- **Prefer “headless + styling” patterns** where reasonable (composition beats configuration). +- **Expose `className` on outer wrappers** and important slots when consumers need to restyle. +- **Keep defaults beautiful** but don’t prevent consumer overrides. +- **Document provenance** for imported modules (registry/forks) in a local `README.md` when relevant. diff --git a/editor/kits/AGENTS.md b/editor/kits/AGENTS.md new file mode 100644 index 000000000..41d894989 --- /dev/null +++ b/editor/kits/AGENTS.md @@ -0,0 +1,90 @@ +# `editor/kits` + +`/editor/kits` is a collection of **opinionated, state-rich UI modules** that are: + +- **Stateful internally**: a kit manages its own local state, effects, and UI behavior. +- **Stateless for the consumer**: the consuming page/scaffold should not need to understand or rewire the kit’s internal state graph. +- **Reusable across the app**: any part of the editor can import and use a kit. + +Kits are “bigger than components”, but “smaller / less app-coupled than scaffolds”. + +## Kits vs Components vs Scaffolds + +Use this mental model when deciding where code belongs: + +| layer | what it is | state | customization | can be used anywhere? | examples | +| ------------- | ------------------------------- | -------------------------------- | ----------------------------- | --------------------- | ------------------------------- | +| `components/` | primitives + small reusable UI | little/no domain state | high (props-first) | yes | buttons, inputs, popovers | +| `kits/` | opinionated feature-ish widgets | **local state inside the kit** | medium (supported knobs only) | **yes** | rich text editor kit | +| `scaffolds/` | app-feature assemblies | **binds to global/editor state** | low/feature-specific | no (feature-scoped) | editor blocks, workbench panels | + +Rule of thumb: + +- If it’s a **primitive building block**, put it in `components/`. +- If it’s a **ready-to-use widget** that needs internal state but should be easy to drop in anywhere, put it in `kits/`. +- If it **depends on global editor/workbench state**, it belongs in `scaffolds/` (or a feature folder under `app/`). + +## Hard constraints (please follow) + +- **No global state coupling**: kits must not require app-global stores (e.g. editor/workbench state) to function. + - Passing data in/out via props is fine (`value`, `onChange`, callbacks). + - Importing types from shared domains is fine; importing global state hooks is not. +- **Avoid Next.js route coupling**: a kit should not import from route segments under `app/` (e.g. `app/(workbench)/...`) or depend on route-only modules. +- **Bounded public API**: expose a small, stable API from the kit’s `index.ts`. Hide internals (subcomponents, implementation hooks, etc.) unless they’re meant to be used externally. +- **Opinionated by design**: do not add “escape hatch” props unless there is a clear, repeated need. Prefer a few supported variants over full customization. +- **No side effects without an injection point**: if a kit needs I/O (uploads, fetches, analytics), prefer passing a function in via props (dependency injection) instead of hardcoding endpoints. + +## Expected kit structure + +Kits are typically self-contained in a folder: + +```txt +kits// + README.md # required: what/why/how (+ provenance if forked) + index.ts # public exports (recommended) + components/ # optional: good practice when the kit grows + ... # implementation files/folders as needed +``` + +General conventions: + +- `index.ts` re-exports the intended public surface. +- Styles are imported by the kit entry component (`import "./styles/index.css";`) rather than leaking via global app CSS. + +## Kits in this directory + +| kit | description | entry | +| ---------------- | ------------------------------------------------ | ----------------------- | +| `minimal-tiptap` | Opinionated rich-text editor kit (Tiptap-based). | `@/kits/minimal-tiptap` | + +## API design guidelines + +Kits should feel like “drop-in components”: + +- **Prefer controlled patterns**: + - `value?: T` + - `onChange?: (value: T) => void` + - Provide sane defaults when `value` is omitted (uncontrolled mode) if it’s useful. +- **Expose only the knobs you can support**: + - e.g. `disabled`, `placeholder`, `autofocus`, `className`, small variant flags. + - If consumers want arbitrary rendering overrides, that’s usually a `components/` concern. +- **Be explicit about output types**: + - If output can be `"html" | "json"`, type it and document it. +- **Keep types portable**: + - Avoid leaking deep dependency types unless the dependency is part of the kit’s contract. + +## Common pitfalls + +- Turning a kit into a scaffold by accident (importing global editor state hooks, relying on page context, etc.). +- Exporting too many internals from `index.ts` (locks you into supporting them forever). +- Adding “custom render props” everywhere (kits are not meant to be fully composable frameworks). +- Shipping global CSS unintentionally (keep CSS imports local to the kit entrypoint). + +## When you modify or add a kit + +Checklist: + +- Keep changes **contained** to the kit folder unless there is a strong reason. +- Ensure the kit is usable from multiple places (no workbench-only assumptions). +- `README.md` is **required** for every kit; keep it updated (especially for forks/sync notes and API decisions). +- If you add non-trivial logic, consider adding a small `*.test.ts` near the kit (or colocated where the logic lives). From fb08a314a98578e5e7520988203e3b0e652cda5b Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 22:59:37 +0900 Subject: [PATCH 02/10] Add documentation for respondent email notifications in Grida Forms, detailing setup, customization, and troubleshooting steps. Update AGENTS.md to include forms documentation link. --- docs/AGENTS.md | 5 ++ docs/forms/respondent-email-notifications.md | 85 ++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/forms/respondent-email-notifications.md diff --git a/docs/AGENTS.md b/docs/AGENTS.md index d8d0ac201..f57145ce8 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -1,3 +1,7 @@ +--- +unlisted: true +--- + # Docs agent guide (`/docs`) This directory is the **source of truth** for documentation content. @@ -44,6 +48,7 @@ unlisted: true | [/docs/math](./math) | math | Math reference, used for internal docs referencing | yes | | [/docs/platform](./platform) | platform | Grida Platform (API/Spec) documents | yes | | [/docs/editor](./editor) | editor | Grida Editor - User Documentation | yes | +| [/docs/forms](./forms) | forms | Grida Forms - User Documentation | yes | | [/docs/canvas](./canvas) | canvas | Grida Canvas SDK - User Documentation | no | | [/docs/cli](./cli) | cli | Grida CLI - User Documentation | yes | | [/docs/together](./together) | together | Contributing, Support, Community, etc | yes | diff --git a/docs/forms/respondent-email-notifications.md b/docs/forms/respondent-email-notifications.md new file mode 100644 index 000000000..d5402858b --- /dev/null +++ b/docs/forms/respondent-email-notifications.md @@ -0,0 +1,85 @@ +--- +title: Respondent email notifications +description: Learn how to send a custom confirmation email to respondents after a form submission in Grida Forms (CIAM verified email required). +--- + +### Respondent email notifications + +Respondent email notifications let you send a **custom confirmation email** to the person who submitted your form. + +This is useful for signup and registration forms where you want to: + +- confirm the submission +- share next steps +- include a reference like a submission ID + +### Before you start (CIAM / verified email) + +Grida sends respondent emails **only when CIAM is used** and the respondent has a **verified email**. + +Practically, this means: + +- your form should include a `challenge_email` field (CIAM email verification) +- the email is sent to the verified email associated with the submission (not to an arbitrary input field) + +### How to enable respondent email notifications + +1. Open your **Form** in the Grida editor. +2. In the left sidebar, click **Connect**. +3. Click **Channels**. +4. Under **Email Notifications**, find **Respondent email notifications**. +5. Toggle **Enable** on. +6. Click **Save**. + +### How to customize the email + +1. Open **Connect → Channels → Email Notifications** (same as above). +2. Configure the email fields: + - **Reply-To** (optional): where replies should go (e.g. `support@yourdomain.com`) + - **Subject**: the email subject template + - **From name** (optional): the sender display name (e.g. `Acme Support`) + - **Body (HTML)**: the email body template (HTML) +3. Use the built-in preview to check your subject/body. +4. Click **Save**. + +### What gets sent (high level) + +- **Recipient**: the respondent’s **verified email** (CIAM) +- **From email**: a fixed no-reply address (display name can be customized with **From name**) +- **When it sends**: after a successful form submission + - if CIAM isn’t present or email isn’t verified, the email is skipped + +### Templating (Handlebars variables) + +Subject and Body support template variables. + +#### Available variables + +- `{{form_title}}` +- `{{response.idx}}` (formatted submission index) +- `{{fields.}}` (submitted fields by field name) + +#### Examples + +Subject: + +```txt +Thanks for registering for {{form_title}} +``` + +Body (HTML): + +```html +

Thanks, {{fields.first_name}}!

+

We received your submission for {{form_title}}.

+

Your registration number: {{response.idx}}

+``` + +### Troubleshooting + +If emails are not being sent: + +- **CIAM not enabled**: ensure your form includes a `challenge_email` field +- **Email not verified**: respondent must complete verification; unverified emails are skipped +- **Missing body template**: sending is skipped if the body is empty +- **Delivery reliability**: sending is currently best-effort inline. Retries/queueing may be added later. From 122a0c0399f8d0407aa73fe43cb10541abab11c2 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 23:10:52 +0900 Subject: [PATCH 03/10] Add Email Template Authoring Kit component and documentation 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. --- .../kits/email-template-authoring/README.md | 24 +++ .../kits/email-template-authoring/index.tsx | 166 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 editor/kits/email-template-authoring/README.md create mode 100644 editor/kits/email-template-authoring/index.tsx diff --git a/editor/kits/email-template-authoring/README.md b/editor/kits/email-template-authoring/README.md new file mode 100644 index 000000000..2efaf2edc --- /dev/null +++ b/editor/kits/email-template-authoring/README.md @@ -0,0 +1,24 @@ +### What + +`email-template-authoring` is a reusable kit that renders an **email-client-style** template authoring UI: + +- an email composer layout (`To`, `Reply-To`, `Subject`, `From name`, `From`, `Body`) + +### Why + +Multiple parts of the editor may need to author email templates with the same UX. This kit: + +- keeps the UX consistent across features +- provides a small controlled API (`state`, `value`, `onValueChange`) +- avoids coupling to global editor/workbench state + +### API (high level) + +Each field is controlled by: + +- `state`: `"disabled" | "off" | "on"` + - `disabled`: visible but read-only/disabled + - `off`: hidden + - `on`: visible and editable +- `value`: field value +- `onValueChange`: receives the new value (when editable) diff --git a/editor/kits/email-template-authoring/index.tsx b/editor/kits/email-template-authoring/index.tsx new file mode 100644 index 000000000..4b9827375 --- /dev/null +++ b/editor/kits/email-template-authoring/index.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupTextarea, +} from "@/components/ui/input-group"; + +export type FieldState = "disabled" | "off" | "on"; + +export type ControlledField = + | { + state: "disabled"; + value: T; + } + | { + state: "off"; + value?: T; + } + | { + state: "on"; + value: T; + onValueChange: (value: T) => void; + disabled?: boolean; + }; + +export type EmailTemplateAuthoringKitProps = { + fields: { + to: ControlledField; + replyTo: ControlledField; + subject: ControlledField; + fromName: ControlledField; + from: ControlledField; + bodyHtml: ControlledField; + }; + + /** + * Optional note shown above the composer (e.g. requirements like CIAM). + */ + notice?: React.ReactNode; + + /** + * Optional helper text shown below the composer. + */ + helper?: React.ReactNode; +}; + +function renderRow({ + label, + field, + placeholder, +}: { + label: string; + field: ControlledField; + placeholder?: string; +}) { + if (field.state === "off") return null; + + const disabled = + field.state === "disabled" ? true : Boolean(field.disabled); + + return ( + + + {label} + + {field.state === "on" ? ( + field.onValueChange(e.target.value)} + /> + ) : ( + + )} + + ); +} + +function renderBody({ + field, + placeholder, +}: { + field: ControlledField; + placeholder?: string; +}) { + if (field.state === "off") return null; + + const disabled = + field.state === "disabled" ? true : Boolean(field.disabled); + + return ( + + {field.state === "on" ? ( + field.onValueChange(e.target.value)} + /> + ) : ( + + )} + + ); +} + +export function EmailTemplateAuthoringKit({ + fields, + notice, + helper, +}: EmailTemplateAuthoringKitProps) { + return ( +
+ {notice} + +
+ {renderRow({ + label: "To:", + field: fields.to, + })} + {renderRow({ + label: "Reply to:", + field: fields.replyTo, + placeholder: "support@yourdomain.com", + })} + {renderRow({ + label: "Subject:", + field: fields.subject, + placeholder: "Thanks, {{fields.first_name}}", + })} + {renderRow({ + label: "From name:", + field: fields.fromName, + placeholder: "Grida Forms", + })} + {renderRow({ + label: "From:", + field: fields.from, + })} + {renderBody({ + field: fields.bodyHtml, + placeholder: + "

Thanks

\n

We received your submission for {{form_title}}.

", + })} +
+ + {helper} +
+ ); +} + From 8c5601ed804c3184e5868008daad859d59572f37 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 4 Feb 2026 23:17:03 +0900 Subject: [PATCH 04/10] Add respondent email notification feature 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. --- database/database-generated.types.ts | 3 + .../(api)/(public)/v1/submit/[id]/hooks.ts | 21 ++ .../notification-respondent-email/route.ts | 135 ++++++++++ .../(api)/(public)/v1/submit/[id]/route.ts | 11 + .../notification-respondent-email/route.ts | 49 ++++ .../[proj]/[id]/connect/channels/page.tsx | 166 ++++-------- .../(workbench)/[org]/[proj]/[id]/layout.tsx | 22 ++ editor/grida-forms-hosted/types.ts | 12 + editor/lib/private/index.ts | 10 + editor/scaffolds/editor/action.ts | 7 + editor/scaffolds/editor/init.ts | 1 + editor/scaffolds/editor/reducer.ts | 12 + editor/scaffolds/editor/state.ts | 8 + ...ification-respondent-email-preferences.tsx | 246 ++++++++++++++++++ .../sidebar/sidebar-mode-connect.tsx | 48 ++-- editor/services/form/respondent-email.test.ts | 35 +++ editor/services/form/respondent-email.ts | 61 +++++ editor/types/private/api.ts | 9 + ...204000000_grida_forms_respondent_email.sql | 15 ++ 19 files changed, 728 insertions(+), 143 deletions(-) create mode 100644 editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts create mode 100644 editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts create mode 100644 editor/scaffolds/settings/notification-respondent-email-preferences.tsx create mode 100644 editor/services/form/respondent-email.test.ts create mode 100644 editor/services/form/respondent-email.ts create mode 100644 supabase/migrations/20260204000000_grida_forms_respondent_email.sql diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 1d5cbf8ac..059e0eb7d 100644 --- a/database/database-generated.types.ts +++ b/database/database-generated.types.ts @@ -1220,6 +1220,7 @@ export type Database = { max_form_responses_by_customer: number | null max_form_responses_in_total: number | null name: string + notification_respondent_email: Json project_id: number scheduling_close_at: string | null scheduling_open_at: string | null @@ -1241,6 +1242,7 @@ export type Database = { max_form_responses_by_customer?: number | null max_form_responses_in_total?: number | null name?: string + notification_respondent_email?: Json project_id: number scheduling_close_at?: string | null scheduling_open_at?: string | null @@ -1262,6 +1264,7 @@ export type Database = { max_form_responses_by_customer?: number | null max_form_responses_in_total?: number | null name?: string + notification_respondent_email?: Json project_id?: number scheduling_close_at?: string | null scheduling_open_at?: string | null diff --git a/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts index 740bec34f..36406fabf 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts @@ -51,6 +51,27 @@ export namespace OnSubmit { }), }); } + + export async function notification_respondent_email({ + form_id, + response_id, + }: { + form_id: string; + response_id: string; + }) { + return fetch( + `${Env.server.HOST}/v1/submit/${form_id}/hooks/notification-respondent-email`, + { + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + response_id, + }), + } + ); + } } export namespace OnSubmitProcessors { diff --git a/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts new file mode 100644 index 000000000..13da52406 --- /dev/null +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import assert from "assert"; +import { notFound } from "next/navigation"; +import validator from "validator"; +import { service_role } from "@/lib/supabase/server"; +import { resend } from "@/clients/resend"; +import { renderRespondentEmail } from "@/services/form/respondent-email"; + +type Params = { id: string }; + +export async function POST( + req: NextRequest, + context: { + params: Promise; + } +) { + 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; + + 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} `, + 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 }); +} diff --git a/editor/app/(api)/(public)/v1/submit/[id]/route.ts b/editor/app/(api)/(public)/v1/submit/[id]/route.ts index a37128181..f78452cdb 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/route.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/route.ts @@ -1114,6 +1114,17 @@ async function submit({ console.error("submit/err/hooks/postindexing", e); } + // 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); + } + // notification hooks are not ready yet // try { // await hook_notifications({ form_id }); diff --git a/editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts b/editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts new file mode 100644 index 000000000..1755ac74d --- /dev/null +++ b/editor/app/(api)/private/editor/settings/notification-respondent-email/route.ts @@ -0,0 +1,49 @@ +import { createFormsClient } from "@/lib/supabase/server"; +import type { + EditorApiResponseOk, + UpdateFormNotificationRespondentEmailRequest, +} from "@/types/private/api"; +import assert from "assert"; +import { notFound } from "next/navigation"; +import { NextRequest, NextResponse } from "next/server"; + +export async function POST(req: NextRequest) { + const data: UpdateFormNotificationRespondentEmailRequest = await req.json(); + + const { + form_id, + enabled, + from_name, + subject_template, + body_html_template, + reply_to, + } = data; + + assert(form_id, "form_id is required"); + + const formsClient = await createFormsClient(); + + 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, + }, + }) + .eq("id", form_id) + .single(); + + if (error) { + console.error(error); + return notFound(); + } + + return NextResponse.json({ + data: null, + error: null, + } satisfies EditorApiResponseOk); +} diff --git a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx index a4460f253..b66ad6916 100644 --- a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx @@ -22,7 +22,6 @@ import { } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { - EnvelopeClosedIcon, LightningBoltIcon, QuestionMarkCircledIcon, } from "@radix-ui/react-icons"; @@ -40,7 +39,6 @@ import { } from "@/components/ui/select"; import MessageAppFrame from "@/components/frames/message-app-frame"; import { bird_sms_fees } from "@/k/sms_fees"; -import MailAppFrame from "@/components/frames/mail-app-frame"; import { Dialog, DialogContent, @@ -59,6 +57,7 @@ import React from "react"; import { MessageCircleIcon } from "lucide-react"; import { useEditorState } from "@/scaffolds/editor"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { NotificationRespondentEmailPreferences } from "@/scaffolds/settings/notification-respondent-email-preferences"; const SMS_DEFAULT_ORIGINATOR = process.env .NEXT_PUBLIC_BIRD_SMS_DEFAULT_ORIGINATOR as string; @@ -80,142 +79,67 @@ export default function ConnectChannels() { - + + SMS Notifications - Add-on + Coming soon } - /> - -
- -
-
- - Originator - - -
-
- - -
-
-
- - - - Email Notifications + SMS notifications are not ready yet. This section is currently + disabled. } /> -
- +
+ -

Dear team,

-

- Im excited to announce the release of our latest feature - update. This release includes several new capabilities that - will help you work more efficiently and effectively. -

-

Some of the key highlights include:

-
    -
  • Improved email search and filtering
  • -
  • Enhanced email templates and signatures
  • -
  • - Seamless integration with our project management tools -
  • -
-

- Weve been working hard to deliver these improvements, and - were confident they will have a positive impact on your - daily workflow. Please let me know if you have any questions - or feedback. -

-

- Best regards, -
- Jared -

- + phone: SMS_DEFAULT_ORIGINATOR, + }} + messages={[ + { + message: "Event is openning soon. Register now!", + role: "incoming", + }, + { + message: "Your submission has been received.", + role: "incoming", + }, + ]} + /> +
+
+ + Originator + + +
+
+ + +
-
- - From - - -
diff --git a/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx b/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx index 1436062ec..f7c23ab6f 100644 --- a/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx +++ b/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx @@ -230,6 +230,28 @@ export default async function Layout({ scheduling_close_at: form.scheduling_close_at, scheduling_tz: form.scheduling_tz || undefined, }, + 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, + }, form_security: { unknown_field_handling_strategy: form.unknown_field_handling_strategy, diff --git a/editor/grida-forms-hosted/types.ts b/editor/grida-forms-hosted/types.ts index b7aff0bee..d9539c66c 100644 --- a/editor/grida-forms-hosted/types.ts +++ b/editor/grida-forms-hosted/types.ts @@ -26,6 +26,18 @@ export interface Form { is_max_form_responses_in_total_enabled: boolean; max_form_responses_by_customer: number | null; max_form_responses_in_total: number | null; + /** + * Admin-configurable respondent email notification settings. + * + * Stored in DB as `jsonb`. + */ + notification_respondent_email: { + enabled?: boolean; + from_name?: string | null; + subject_template?: string | null; + body_html_template?: string | null; + reply_to?: string | null; + } | null; project_id: number; title: string; unknown_field_handling_strategy: FormResponseUnknownFieldHandlingStrategyType; diff --git a/editor/lib/private/index.ts b/editor/lib/private/index.ts index abdce11b8..52ec1057b 100644 --- a/editor/lib/private/index.ts +++ b/editor/lib/private/index.ts @@ -15,6 +15,7 @@ import { UpdateFormAccessMaxResponseInTotalRequest, UpdateFormMethodRequest, UpdateFormRedirectAfterSubmissionRequest, + UpdateFormNotificationRespondentEmailRequest, UpdateFormScheduleRequest, UpdateFormUnknownFieldsHandlingStrategyRequest, XSupabasePrivateApiTypes, @@ -162,6 +163,15 @@ export namespace PrivateEditorApi { data ); } + + export function updateNotificationRespondentEmail( + data: UpdateFormNotificationRespondentEmailRequest + ) { + return Axios.post( + `/private/editor/settings/notification-respondent-email`, + data + ); + } } export namespace Schema { diff --git a/editor/scaffolds/editor/action.ts b/editor/scaffolds/editor/action.ts index ceadcfc0b..6c87360ae 100644 --- a/editor/scaffolds/editor/action.ts +++ b/editor/scaffolds/editor/action.ts @@ -61,6 +61,7 @@ export type EditorAction = | EditorThemeBackgroundAction | FormCampaignPreferencesAction | FormEndingPreferencesAction + | FormNotificationRespondentEmailPreferencesAction | FormStartPageInitAction | FormStartPageRemoveAction; @@ -448,6 +449,12 @@ export interface FormEndingPreferencesAction extends Partial< type: "editor/form/ending/preferences"; } +export interface FormNotificationRespondentEmailPreferencesAction extends Partial< + EditorState["form"]["notification_respondent_email"] +> { + type: "editor/form/notification_respondent_email/preferences"; +} + export interface FormStartPageInitAction { type: "editor/form/startpage/init"; template: grida.program.document.template.TemplateDocumentDefinition; diff --git a/editor/scaffolds/editor/init.ts b/editor/scaffolds/editor/init.ts index 5781f19d2..35db4e89f 100644 --- a/editor/scaffolds/editor/init.ts +++ b/editor/scaffolds/editor/init.ts @@ -635,6 +635,7 @@ function initialFormEditorState(init: FormDocumentEditorInit): EditorState { form_title: init.form_title, campaign: init.campaign, ending: init.ending, + notification_respondent_email: init.notification_respondent_email, fields: init.fields, form_security: init.form_security, available_field_ids: block_available_field_ids, diff --git a/editor/scaffolds/editor/reducer.ts b/editor/scaffolds/editor/reducer.ts index a6a5a1fa0..ea6a9f2fa 100644 --- a/editor/scaffolds/editor/reducer.ts +++ b/editor/scaffolds/editor/reducer.ts @@ -24,6 +24,7 @@ import type { EditorThemePoweredByBrandingAction, FormCampaignPreferencesAction, FormEndingPreferencesAction, + FormNotificationRespondentEmailPreferencesAction, EditorThemeAppearanceAction, // DataGridViewAction, DataGridTableViewAction, @@ -350,6 +351,17 @@ export function reducer(state: EditorState, action: EditorAction): EditorState { }; }); } + case "editor/form/notification_respondent_email/preferences": { + const { type, ...pref } = < + FormNotificationRespondentEmailPreferencesAction + >action; + return produce(state, (draft) => { + draft.form.notification_respondent_email = { + ...draft.form.notification_respondent_email, + ...pref, + }; + }); + } case "editor/form/startpage/init": { const { template: startpage } = action; return produce(state, (draft) => { diff --git a/editor/scaffolds/editor/state.ts b/editor/scaffolds/editor/state.ts index b19a88443..cea74999a 100644 --- a/editor/scaffolds/editor/state.ts +++ b/editor/scaffolds/editor/state.ts @@ -100,6 +100,7 @@ export interface FormDocumentEditorInit extends BaseDocumentEditorInit { form_id: string; campaign: EditorState["form"]["campaign"]; form_security: EditorState["form"]["form_security"]; + notification_respondent_email: EditorState["form"]["notification_respondent_email"]; /** * the start document as-is (the typing is ignored - this should be assured before being actually passed under the provider) @@ -571,6 +572,13 @@ export interface FormEditorState ending_page_template_id: EndingPageTemplateID | null; ending_page_i18n_overrides: EndingPageI18nOverrides | null; }; + notification_respondent_email: { + enabled: boolean; + from_name: string | null; + subject_template: string | null; + body_html_template: string | null; + reply_to: string | null; + }; form_security: { unknown_field_handling_strategy: FormResponseUnknownFieldHandlingStrategyType; method: FormMethod; diff --git a/editor/scaffolds/settings/notification-respondent-email-preferences.tsx b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx new file mode 100644 index 000000000..d6a660881 --- /dev/null +++ b/editor/scaffolds/settings/notification-respondent-email-preferences.tsx @@ -0,0 +1,246 @@ +"use client"; + +import React, { useMemo } from "react"; +import { + PreferenceBody, + PreferenceBox, + PreferenceBoxFooter, + PreferenceBoxHeader, + PreferenceDescription, +} from "@/components/preferences"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Spinner } from "@/components/ui/spinner"; +import { PrivateEditorApi } from "@/lib/private"; +import { toast } from "sonner"; +import { Controller, useForm, useWatch } from "react-hook-form"; +import { useEditorState } from "@/scaffolds/editor"; +import { Badge } from "@/components/ui/badge"; +import { EmailTemplateAuthoringKit } from "@/kits/email-template-authoring"; +import MailAppFrame from "@/components/frames/mail-app-frame"; + +type FormValues = { + enabled: boolean; + from_name: string | null; + subject_template: string | null; + body_html_template: string | null; + reply_to: string | null; +}; + +export function NotificationRespondentEmailPreferences() { + const [state, dispatch] = useEditorState(); + const { form } = state; + + const isCiamEnabled = useMemo(() => { + return form.fields.some((f) => f.type === "challenge_email"); + }, [form.fields]); + + const initial = form.notification_respondent_email; + + const { + handleSubmit, + control, + setValue, + formState: { isSubmitting, isDirty }, + reset, + } = useForm({ + 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, + }, + }); + + const enabled = useWatch({ control, name: "enabled" }); + const reply_to = useWatch({ control, name: "reply_to" }); + const from_name = useWatch({ control, name: "from_name" }); + const subject_template = useWatch({ control, name: "subject_template" }); + const body_html_template = useWatch({ control, name: "body_html_template" }); + + const onSubmit = async (data: FormValues) => { + const req = PrivateEditorApi.Settings.updateNotificationRespondentEmail({ + form_id: form.form_id, + enabled: data.enabled, + from_name: data.from_name, + subject_template: data.subject_template, + body_html_template: data.body_html_template, + reply_to: data.reply_to, + }).then(() => { + dispatch({ + type: "editor/form/notification_respondent_email/preferences", + enabled: data.enabled, + from_name: data.from_name, + subject_template: data.subject_template, + body_html_template: data.body_html_template, + reply_to: data.reply_to, + }); + }); + + try { + await toast.promise(req, { + loading: "Saving...", + success: "Saved", + error: "Failed", + }); + reset(data); + } catch (e) { + // toast.promise handles UI + } + }; + + const disabled = !enabled; + const inputDisabled = disabled || !isCiamEnabled; + + return ( + + + Respondent email notifications + Pro + + } + description={ + <> + Send a confirmation email after a successful submission (CIAM / + verified email only). + + } + actions={ + ( +
+ +
+ )} + /> + } + /> + +
+ + {body_html_template?.trim() ? ( +
+ ) : ( + <> +

Thanks for your submission.

+

+ Tip: add a body HTML template below to preview the email + content here. +

+ + )} + +
+
+ + This notification requires CIAM email verification. Add a{" "} + challenge_email field to enable verified + respondent email sending. + + ) : null + } + helper={ + + Supported variables: {"{{form_title}}"},{" "} + {"{{response.idx}}"},{" "} + {"{{fields.}}"}. + + } + fields={{ + to: { state: "disabled", value: "Respondent (verified email)" }, + replyTo: { + state: "on", + value: reply_to ?? "", + disabled: inputDisabled, + onValueChange: (v: string) => + setValue("reply_to", v || null, { + shouldDirty: true, + }), + }, + subject: { + state: "on", + value: subject_template ?? "", + disabled: inputDisabled, + onValueChange: (v: string) => + setValue("subject_template", v || null, { + shouldDirty: true, + }), + }, + fromName: { + state: "on", + value: from_name ?? "", + disabled: inputDisabled, + onValueChange: (v: string) => + setValue("from_name", v || null, { shouldDirty: true }), + }, + from: { + state: "disabled", + value: `${from_name?.trim() || "Grida Forms"} `, + }, + bodyHtml: { + state: "on", + value: body_html_template ?? "", + disabled: inputDisabled, + onValueChange: (v: string) => + setValue("body_html_template", v || null, { + shouldDirty: true, + }), + }, + }} + /> + + + + + + + ); +} diff --git a/editor/scaffolds/sidebar/sidebar-mode-connect.tsx b/editor/scaffolds/sidebar/sidebar-mode-connect.tsx index bd3aa05d0..1d2e1f399 100644 --- a/editor/scaffolds/sidebar/sidebar-mode-connect.tsx +++ b/editor/scaffolds/sidebar/sidebar-mode-connect.tsx @@ -69,17 +69,19 @@ function DoctypeForms() { Share - - {/* */} - - - Domain - - enterprise - - - {/* */} - + + + Domain + + enterprise + + @@ -295,17 +297,19 @@ function DoctypeSite() { Share */} - - {/* */} - - - Domain - - enterprise - - - {/* */} - + + + Domain + + enterprise + + diff --git a/editor/services/form/respondent-email.test.ts b/editor/services/form/respondent-email.test.ts new file mode 100644 index 000000000..d2701a0bc --- /dev/null +++ b/editor/services/form/respondent-email.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import { + renderRespondentEmail, + stringifyFields, +} from "./respondent-email"; + +describe("respondent-email", () => { + test("stringifyFields JSON-stringifies non-primitive values", () => { + const fields = stringifyFields({ + ok: true, + n: 1, + obj: { a: 1 }, + arr: [1, 2], + }); + + expect(fields.ok).toBe("true"); + expect(fields.n).toBe("1"); + expect(fields.obj).toBe(JSON.stringify({ a: 1 })); + expect(fields.arr).toBe(JSON.stringify([1, 2])); + }); + + test("renderRespondentEmail renders handlebars variables", () => { + const { subject, html } = renderRespondentEmail({ + form_title: "MyForm", + raw: { first_name: "Ada" }, + response_local_index: 12, + response_local_id: "abc", + subject_template: "Hello {{fields.first_name}}", + body_html_template: "

{{form_title}} {{response.idx}}

", + }); + + expect(subject).toBe("Hello Ada"); + expect(html).toBe("

MyForm #012

"); + }); +}); diff --git a/editor/services/form/respondent-email.ts b/editor/services/form/respondent-email.ts new file mode 100644 index 000000000..f2d25de80 --- /dev/null +++ b/editor/services/form/respondent-email.ts @@ -0,0 +1,61 @@ +import { render } from "@/lib/templating/template"; +import { fmt_local_index } from "@/utils/fmt"; + +export function toStringValue(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + try { + return JSON.stringify(v); + } catch { + return String(v); + } +} + +export function stringifyFields(raw: Record) { + return Object.keys(raw).reduce( + (acc, key) => { + acc[key] = toStringValue(raw[key]); + return acc; + }, + {} as Record + ); +} + +export function renderRespondentEmail({ + form_title, + raw, + response_local_index, + response_local_id, + subject_template, + body_html_template, +}: { + form_title: string; + raw: Record; + 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, + }; +} diff --git a/editor/types/private/api.ts b/editor/types/private/api.ts index 7eeaa962f..4f371ff98 100644 --- a/editor/types/private/api.ts +++ b/editor/types/private/api.ts @@ -112,6 +112,15 @@ export type UpdateFormUnknownFieldsHandlingStrategyRequest = { strategy?: FormResponseUnknownFieldHandlingStrategyType; }; +export type UpdateFormNotificationRespondentEmailRequest = { + form_id: string; + enabled: boolean; + from_name?: string | null; + subject_template?: string | null; + body_html_template?: string | null; + reply_to?: string | null; +}; + export type CreateNewSchemaTableRequest = { schema_id: string; table_name: string; diff --git a/supabase/migrations/20260204000000_grida_forms_respondent_email.sql b/supabase/migrations/20260204000000_grida_forms_respondent_email.sql new file mode 100644 index 000000000..24680132b --- /dev/null +++ b/supabase/migrations/20260204000000_grida_forms_respondent_email.sql @@ -0,0 +1,15 @@ +-- grida_forms: respondent email notification settings +-- +-- Adds a per-form `notification_respondent_email` JSONB config column. + +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; + +DROP INDEX IF EXISTS grida_forms_form_notification_respondent_email_to_attribute_id_idx; + From 35203b0761bf87f388fe54f15dc39d0ce55aefe5 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 00:20:52 +0900 Subject: [PATCH 05/10] clean migration + schema constraints --- ...204000000_grida_forms_respondent_email.sql | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/supabase/migrations/20260204000000_grida_forms_respondent_email.sql b/supabase/migrations/20260204000000_grida_forms_respondent_email.sql index 24680132b..b478d26f3 100644 --- a/supabase/migrations/20260204000000_grida_forms_respondent_email.sql +++ b/supabase/migrations/20260204000000_grida_forms_respondent_email.sql @@ -1,15 +1,32 @@ -- grida_forms: respondent email notification settings -- -- Adds a per-form `notification_respondent_email` JSONB config column. +-- This migration is intentionally clean (no legacy cleanup) because it was not shipped. 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; -DROP INDEX IF EXISTS grida_forms_form_notification_respondent_email_to_attribute_id_idx; +-- JSON schema guard (requires `pg_jsonschema` extension; installed under schema `extensions`). +ALTER TABLE grida_forms.form + DROP CONSTRAINT IF EXISTS form_notification_respondent_email_schema_check; + +ALTER TABLE grida_forms.form + ADD CONSTRAINT form_notification_respondent_email_schema_check + CHECK ( + extensions.jsonb_matches_schema( + '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "from_name": { "type": ["string", "null"] }, + "subject_template": { "type": ["string", "null"] }, + "body_html_template": { "type": ["string", "null"] }, + "reply_to": { "type": ["string", "null"] } + } + }'::json, + notification_respondent_email + ) + ); From 37e2852c466dab827e1a5aca082f453bf466fba6 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 00:22:14 +0900 Subject: [PATCH 06/10] Update AGENTS.md to include new 'email-template-authoring' kit entry for email-client-style template authoring UI. --- editor/kits/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/editor/kits/AGENTS.md b/editor/kits/AGENTS.md index 41d894989..d9928158d 100644 --- a/editor/kits/AGENTS.md +++ b/editor/kits/AGENTS.md @@ -56,6 +56,7 @@ General conventions: | 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` | ## API design guidelines From 4db136c2f2c4979c2f771f7468b7c704a97ee03c Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 00:32:20 +0900 Subject: [PATCH 07/10] Add S2S key authentication for notification email endpoint --- editor/.env.example | 12 +++++++--- .../notification-respondent-email/route.ts | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/editor/.env.example b/editor/.env.example index 04b54d83d..273a06c61 100644 --- a/editor/.env.example +++ b/editor/.env.example @@ -44,6 +44,14 @@ NEXT_PUBLIC_OPENAI_BEST_MODEL_ID="gpt-4o-mini" # resend RESEND_API_KEY='re_123' +# 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" + # upstash redis (used for rate limiting) # @see https://upstash.com/docs/redis/overall/getstarted UPSTASH_REDIS_REST_URL="" @@ -57,9 +65,7 @@ VERCEL_TEAM_ID='' # Vercel Project ID that can be found here: https://vercel.com///settings VERCEL_PROJECT_ID='' -# internal (proxy -> internal api auth) -# used to authenticate requests from `proxy.ts` to `/internal/resolve-host` -GRIDA_INTERNAL_PROXY_TOKEN="" + # telemetry (sentry) diff --git a/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts index 13da52406..fc97f830f 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts @@ -8,12 +8,35 @@ import { renderRespondentEmail } from "@/services/form/respondent-email"; type Params = { id: string }; +/** + * Guard this public hook endpoint with a shared S2S key. + * + * This route uses `service_role` and can send emails, so it must not be + * callable by arbitrary third-parties. + */ +const GRIDA_S2S_PRIVATE_API_KEY = process.env.GRIDA_S2S_PRIVATE_API_KEY; + export async function POST( req: NextRequest, context: { params: Promise; } ) { + const provided = + req.headers.get("x-grida-s2s-key") ?? req.headers.get("x-hook-secret"); + if (!GRIDA_S2S_PRIVATE_API_KEY) { + console.error( + "notification-respondent-email/err/misconfigured: GRIDA_S2S_PRIVATE_API_KEY missing" + ); + return NextResponse.json({ ok: false }, { status: 500 }); + } + if (!provided) { + return NextResponse.json({ ok: false }, { status: 401 }); + } + if (provided !== GRIDA_S2S_PRIVATE_API_KEY) { + return NextResponse.json({ ok: false }, { status: 403 }); + } + const { id: form_id } = await context.params; const { response_id } = await req.json(); From cc0637b03054a4e9ed8450264b3575d9e3c988c7 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 00:32:42 +0900 Subject: [PATCH 08/10] Refactor respondent email notification configuration 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. --- database/database.types.ts | 39 +++++++++++++++++++ .../notification-respondent-email/route.ts | 8 +--- .../(workbench)/[org]/[proj]/[id]/layout.tsx | 25 +++++------- editor/grida-forms-hosted/types.ts | 9 +---- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/database/database.types.ts b/database/database.types.ts index 73a020873..f8e582af6 100644 --- a/database/database.types.ts +++ b/database/database.types.ts @@ -34,6 +34,21 @@ type SystemSchema_Favicon = { type DBDocType = DatabaseGenerated["public"]["Enums"]["doctype"]; +/** + * `grida_forms.form.notification_respondent_email` + * + * DB-enforced JSON schema (see migration): + * - optional keys only + * - no additional properties + */ +export type FormNotificationRespondentEmailConfig = { + enabled?: boolean; + from_name?: string | null; + subject_template?: string | null; + body_html_template?: string | null; + reply_to?: string | null; +}; + // Override the type for a specific column in a view: export type Database = MergeDeep< DatabaseGenerated, @@ -108,6 +123,30 @@ export type Database = MergeDeep< }; }; }; + grida_forms: { + Tables: { + form: { + Row: Omit< + DatabaseGenerated["grida_forms"]["Tables"]["form"]["Row"], + "notification_respondent_email" + > & { + notification_respondent_email: FormNotificationRespondentEmailConfig; + }; + Insert: Omit< + DatabaseGenerated["grida_forms"]["Tables"]["form"]["Insert"], + "notification_respondent_email" + > & { + notification_respondent_email?: FormNotificationRespondentEmailConfig; + }; + Update: Omit< + DatabaseGenerated["grida_forms"]["Tables"]["form"]["Update"], + "notification_respondent_email" + > & { + notification_respondent_email?: FormNotificationRespondentEmailConfig; + }; + }; + }; + }; grida_west_referral: { Views: { campaign_public: { diff --git a/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts index fc97f830f..c565a2ae7 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts @@ -60,13 +60,7 @@ export async function POST( 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; - }>; + const cfg = form.notification_respondent_email; if (!cfg.enabled) { return NextResponse.json( diff --git a/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx b/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx index f7c23ab6f..39ab70a0b 100644 --- a/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx +++ b/editor/app/(workbench)/[org]/[proj]/[id]/layout.tsx @@ -41,6 +41,7 @@ import { DontCastJsonProperties } from "@/types/supabase-ext"; import { xsb_table_conn_init } from "@/scaffolds/editor/init"; import { SidebarProvider } from "@/components/ui/sidebar"; import { Win32LinuxWindowSafeArea } from "@/host/desktop"; +import type { FormNotificationRespondentEmailConfig } from "@app/database"; const inter = Inter({ subsets: ["latin"] }); @@ -176,6 +177,10 @@ export default async function Layout({ fields: FormFieldDefinition[]; }; + const notification_respondent_email = + (form.notification_respondent_email ?? + {}) as FormNotificationRespondentEmailConfig; + const supabase_connection_state = form.supabase_connection ? await client.getXSBMainTableConnectionState(form.supabase_connection) : null; @@ -232,25 +237,15 @@ export default async function Layout({ }, notification_respondent_email: { enabled: - ( - (form.notification_respondent_email ?? {}) as any - )?.enabled ?? false, + notification_respondent_email.enabled ?? false, from_name: - ( - (form.notification_respondent_email ?? {}) as any - )?.from_name ?? null, + notification_respondent_email.from_name ?? null, subject_template: - ( - (form.notification_respondent_email ?? {}) as any - )?.subject_template ?? null, + notification_respondent_email.subject_template ?? null, body_html_template: - ( - (form.notification_respondent_email ?? {}) as any - )?.body_html_template ?? null, + notification_respondent_email.body_html_template ?? null, reply_to: - ( - (form.notification_respondent_email ?? {}) as any - )?.reply_to ?? null, + notification_respondent_email.reply_to ?? null, }, form_security: { unknown_field_handling_strategy: diff --git a/editor/grida-forms-hosted/types.ts b/editor/grida-forms-hosted/types.ts index d9539c66c..2f26b567f 100644 --- a/editor/grida-forms-hosted/types.ts +++ b/editor/grida-forms-hosted/types.ts @@ -1,6 +1,7 @@ import type { IpInfo } from "@/clients/ipinfo"; import type palettes from "@/theme/palettes"; import type { tokens } from "@grida/tokens"; +import type { FormNotificationRespondentEmailConfig } from "@app/database"; import type { CountryCode } from "libphonenumber-js/core"; import type { Appearance, @@ -31,13 +32,7 @@ export interface Form { * * Stored in DB as `jsonb`. */ - notification_respondent_email: { - enabled?: boolean; - from_name?: string | null; - subject_template?: string | null; - body_html_template?: string | null; - reply_to?: string | null; - } | null; + notification_respondent_email: FormNotificationRespondentEmailConfig; project_id: number; title: string; unknown_field_handling_strategy: FormResponseUnknownFieldHandlingStrategyType; From a4f9b67016b69d7bf35ca319f8c0f024a68cedce Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 00:49:51 +0900 Subject: [PATCH 09/10] chore --- .../app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx index b66ad6916..1314ec50d 100644 --- a/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/[id]/connect/channels/page.tsx @@ -110,7 +110,7 @@ export default function ConnectChannels() { }} messages={[ { - message: "Event is openning soon. Register now!", + message: "Event is opening soon. Register now!", role: "incoming", }, { From 4244771c218225176ffb69c160aa4374654dcef2 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 5 Feb 2026 20:27:20 +0900 Subject: [PATCH 10/10] Add S2S key to request headers for email notifications 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. --- editor/app/(api)/(public)/v1/submit/[id]/hooks.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts index 36406fabf..2ac00fd26 100644 --- a/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts @@ -4,6 +4,8 @@ import { Env } from "@/env"; import { resend } from "@/clients/resend"; import EmailTemplate from "@/theme/templates-email/formcomplete/default"; +const GRIDA_S2S_PRIVATE_API_KEY = process.env.GRIDA_S2S_PRIVATE_API_KEY; + const bird = new Bird( process.env.BIRD_WORKSPACE_ID as string, process.env.BIRD_SMS_CHANNEL_ID as string, @@ -64,6 +66,9 @@ export namespace OnSubmit { { headers: { "Content-Type": "application/json", + ...(GRIDA_S2S_PRIVATE_API_KEY + ? { "x-grida-s2s-key": GRIDA_S2S_PRIVATE_API_KEY } + : {}), }, method: "POST", body: JSON.stringify({