diff --git a/database/database-generated.types.ts b/database/database-generated.types.ts index 1d5cbf8ac6..059e0eb7d4 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/database/database.types.ts b/database/database.types.ts index 73a020873b..f8e582af6d 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/docs/AGENTS.md b/docs/AGENTS.md index d8d0ac2016..f57145ce84 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 0000000000..d5402858b0 --- /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. diff --git a/editor/.env.example b/editor/.env.example index 04b54d83df..273a06c616 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.ts b/editor/app/(api)/(public)/v1/submit/[id]/hooks.ts index 740bec34f6..2ac00fd266 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, @@ -51,6 +53,30 @@ 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", + ...(GRIDA_S2S_PRIVATE_API_KEY + ? { "x-grida-s2s-key": GRIDA_S2S_PRIVATE_API_KEY } + : {}), + }, + 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 0000000000..c565a2ae71 --- /dev/null +++ b/editor/app/(api)/(public)/v1/submit/[id]/hooks/notification-respondent-email/route.ts @@ -0,0 +1,152 @@ +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 }; + +/** + * 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(); + + 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; + + 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 a371281819..f78452cdb8 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 0000000000..1755ac74d4 --- /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 a4460f2539..1314ec50d3 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 opening 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 1436062ec9..39ab70a0b9 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; @@ -230,6 +235,18 @@ export default async function Layout({ scheduling_close_at: form.scheduling_close_at, scheduling_tz: form.scheduling_tz || undefined, }, + notification_respondent_email: { + enabled: + notification_respondent_email.enabled ?? false, + from_name: + notification_respondent_email.from_name ?? null, + subject_template: + notification_respondent_email.subject_template ?? null, + body_html_template: + notification_respondent_email.body_html_template ?? null, + reply_to: + notification_respondent_email.reply_to ?? null, + }, form_security: { unknown_field_handling_strategy: form.unknown_field_handling_strategy, diff --git a/editor/components/AGENTS.md b/editor/components/AGENTS.md new file mode 100644 index 0000000000..757a5d0028 --- /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/grida-forms-hosted/types.ts b/editor/grida-forms-hosted/types.ts index b7aff0bee6..2f26b567f9 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, @@ -26,6 +27,12 @@ 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: FormNotificationRespondentEmailConfig; project_id: number; title: string; unknown_field_handling_strategy: FormResponseUnknownFieldHandlingStrategyType; diff --git a/editor/kits/AGENTS.md b/editor/kits/AGENTS.md new file mode 100644 index 0000000000..d9928158d9 --- /dev/null +++ b/editor/kits/AGENTS.md @@ -0,0 +1,91 @@ +# `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` | +| `email-template-authoring` | Email-client-style template authoring UI. | `@/kits/email-template-authoring` | + +## 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). diff --git a/editor/kits/email-template-authoring/README.md b/editor/kits/email-template-authoring/README.md new file mode 100644 index 0000000000..2efaf2edc4 --- /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 0000000000..4b98273754 --- /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} +
+ ); +} + diff --git a/editor/lib/private/index.ts b/editor/lib/private/index.ts index abdce11b8e..52ec1057b0 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 ceadcfc0bf..6c87360aee 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 5781f19d28..35db4e89f7 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 a6a5a1fa07..ea6a9f2fa2 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 b19a88443d..cea74999ae 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 0000000000..d6a660881a --- /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 bd3aa05d04..1d2e1f3991 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 0000000000..d2701a0bc1 --- /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 0000000000..f2d25de80a --- /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 7eeaa962fc..4f371ff988 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 0000000000..b478d26f3f --- /dev/null +++ b/supabase/migrations/20260204000000_grida_forms_respondent_email.sql @@ -0,0 +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 + ADD COLUMN IF NOT EXISTS notification_respondent_email jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- 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 + ) + ); +