GForms Add-on — Developer Reference
Overview
The GForms GDPR Clause Generator is a multi-tenant feature that spans two independent clients (a Google Forms sidebar and a Next.js web page) and a shared API backend. Both clients consume the same two API endpoints; the ROPA app is the single source of truth for processing activities and clause text.
Google Forms sidebar (Apps Script)
└─ GET /api/gforms/locales/:locale ← UI messages (on open + locale change)
└─ GET /api/gforms/activities?email=…&locale=…
└─ POST /api/gforms/clause
Next.js web page (/[locale]/gforms)
└─ GET /api/gforms/activities?locale=en
└─ POST /api/gforms/clause
└─ Google Forms REST API (client-side OAuth, forms.body scope)
File Map
Apps Script (GForms/ — not part of the Next.js build)
| File | Role |
|---|---|
constants.gs | ROPA_API_BASE and DefaultLocale |
ShowSidebar.gs | Opens sidebar; fetches messages + activities on load; exposes loadMessages and loadActivities for locale changes |
ProcessForm.gs | Receives questionnaire answers; calls clause API; writes items to the form |
Clausules_RGPD.html | Sidebar HTML/UI |
Logging.gs | Sheets log + summary email per run |
These files are copied manually into the Apps Script editor. They are not imported by any Next.js code.
Next.js (src/)
| File | Role |
|---|---|
src/lib/gforms/generateClause.ts | Pure function — questionnaire → clause items |
src/lib/gforms/resolveGformsUser.ts | Server-only auth helper |
src/lib/gforms/googleFormsApi.ts | Client-side Google Forms REST API wrapper |
src/app/api/gforms/activities/route.ts | GET /api/gforms/activities |
src/app/api/gforms/clause/route.ts | POST /api/gforms/clause |
src/app/api/gforms/locales/[locale]/route.ts | GET /api/gforms/locales/:locale — UI messages |
src/app/[locale]/gforms/page.tsx | Clerk-protected web page (server component) |
src/lib/ui/gforms/GFormsForm.tsx | Client component — questionnaire form |
src/lib/ui/gforms/ClauseOutput.tsx | Client component — clause display + copy + Google Forms button |
Authentication: Dual-Mode
Both API routes support two authentication modes transparently via resolveGformsUser().
resolveGformsUser(emailParam: string | null)
│
├─ auth().userId present? → use Clerk session (web page)
│
└─ emailParam provided? → clerkClient().users.getUserList({ emailAddress })
then getOrganizationMembershipList({ userId })
(Apps Script — Google account email)
Web page — Clerk session cookie is forwarded automatically. emailParam is null.
Apps Script — no session cookie exists. ShowSidebar.gs reads the Google account email via Session.getActiveUser().getEmail() and passes it as ?email=. The API looks up the Clerk user by email address.
If neither credential is present, resolveGformsUser returns { error: "Unauthorized", status: 401 }.
Multi-org resolution
After resolving the Clerk user ID, the helper fetches all org memberships and calls getOrganizationByClerkOrganizationId for each one. The result is an array of GformsOrg objects. The routes then:
- If
shortNamequery param / body field is provided: find that org in the array (403 if not found). - If the array has exactly one org: use it directly.
- Otherwise: return
{ requiresOrgSelection: true, orgs: [...] }and let the caller handle org selection.
The web page (GFormsForm.tsx) handles this automatically by showing an org picker. The Apps Script sidebar shows an org picker too; after the user selects an org the locale list updates and activities reload.
Note: if
resolveGformsUserreturnsrequiresOrgSelection: trueat the initial load (before an org is chosen), the sidebar displays the org picker but no activities yet. This case is handled in the sidebar HTML.
generateClause — Pure Function
Location: src/lib/gforms/generateClause.ts
Takes ClauseFormData, a GformsActivity, and a ClauseTranslations object. Has no I/O and no side effects.
no-clause rule: anonymous === "yes" AND indirect !== "yes" → { needsClause: false }
otherwise:
- iterate CATEGORY_KEYS in order (1ethnics … 8communication)
- collect t[consentXxx] for each key present in formData
- substitute {url} in t.infoClauseText with activity.publicUrl
- return { needsClause: true, consentTitle, consentItems[], infoClauseTitle, infoClause }
consentItems is empty when no special categories are checked — in that case the consent checkbox is omitted by ClauseOutput, but the informational clause is still generated and required.
The translations object is assembled by the route handler using getTranslations({ locale, namespace: "gforms" }) (next-intl server API). Keeping I/O out of generateClause keeps it fully testable as Tier 1.
API Routes
GET /api/gforms/activities
Source: src/app/api/gforms/activities/route.ts
| Step | What happens |
|---|---|
| 1 | Parse locale, email, shortName from query params. Return 400 if locale is missing. |
| 2 | Call resolveGformsUser(email). Propagate any error response. |
| 3 | Resolve target org (see multi-org resolution above). |
| 4 | Call getActivitiesForOrgLocale(shortName, locale). Propagate any error. |
| 5 | Return { activities, shortName, locales, defaultLocale }. |
getActivitiesForOrgLocale (in resolveGformsUser.ts):
- Fetch org by
shortName→ find the matching ROPA ref by locale → fetch that ROPA. - Flatten all OUs → collect
{ activityId, activityName }pairs. - Build
publicUrl:https://<shortName>.<NEXT_PUBLIC_SITE_DOMAIN>/<locale>/<activityId>(useshttp://…localhost:3000in development). - Sort alphabetically by name and return.
GET /api/gforms/locales/:locale
Source: src/app/api/gforms/locales/[locale]/route.ts
Returns the full gforms message namespace for the requested locale. Used exclusively by the Apps Script sidebar to localise its UI.
- Auth: none — these are public UI strings.
- Locale validation: rejects locales not in
routing.localeswith 400. - Implementation: calls
loadTranslations(locale)(file-system read with in-memory cache) and returnstranslations.gforms. - Consumed by:
ShowSidebar.gs→fetchMessagesFromApi(locale)on open;loadMessages(locale)on locale change viagoogle.script.run.
POST /api/gforms/clause
Source: src/app/api/gforms/clause/route.ts
| Step | What happens |
|---|---|
| 1 | Parse JSON body. Return 400 on parse error or missing locale/activityId/formData. |
| 2 | Call resolveGformsUser(email ?? null). Propagate any error response. |
| 3 | Resolve target org. |
| 4 | Call getActivitiesForOrgLocale to get the full activity list. |
| 5 | Find the activity by numeric ID (coerces string IDs). Return 404 if not found. |
| 6 | Load translations with getTranslations({ locale, namespace: "gforms" }). |
| 7 | Call generateClause(formData, activity, translations). |
| 8 | Return { ...result, activityName }. |
Apps Script Sidebar Flow
showSidebar() (server-side, runs once on open)
└─ fetchActivitiesFromApi(email, DefaultLocale)
└─ fetchMessagesFromApi(locale) ← GET /api/gforms/locales/:locale
└─ template.messages = JSON.stringify(messages)
└─ template.activities / orgs / locales / shortName / collects
└─ HtmlService renders Clausules_RGPD.html → sidebar
Clausules_RGPD.html (browser, inside Google's sidebar iframe)
└─ $(document).ready → updateLanguage(currentMessages) ← from template variable
└─ #locale change → reloadLocale(newLocale)
├─ google.script.run.loadMessages(locale)
│ └─ fetchMessagesFromApi(locale) → updateLanguage(messages)
└─ google.script.run.loadActivities(locale, shortName)
└─ fetchActivitiesFromApi(email, locale, shortName) → repopulate dropdown
└─ collectFormData() → google.script.run.processFormData(formData)
Item tracking across runs (ProcessForm.gs)
After inserting clause items into the form, processFormData stores their numeric
IDs in PropertiesService.getDocumentProperties() under the key gdprClauseItemIds
(a JSON array). On the next run, those IDs are read and any matching items are
deleted before inserting the new ones. This makes removal language-agnostic: the
item title is irrelevant.
Items inserted by script versions that pre-date this mechanism are caught by a
hardcoded list of legacy titles (LEGACY_TITLES in processFormData).
loadMessages and loadActivities are fired in parallel when the locale changes — neither blocks the other.
All outbound HTTP calls from the sidebar go through google.script.run (Apps Script server-side UrlFetchApp), not direct browser fetch, to avoid CORS issues with the ROPA domain.
Web Page Flow
Source: src/app/[locale]/gforms/page.tsx (server component) + GFormsForm.tsx / ClauseOutput.tsx (client components).
page.tsx (server)
└─ resolveGformsUser(null) — uses Clerk session
└─ redirect to sign-in if { error, status: 401 }
└─ render <GFormsForm locale orgs={orgs.orgs} />
GFormsForm (client)
└─ useEffect on [selectedShortName, selectedLocale]
└─ fetch GET /api/gforms/activities?shortName=…&locale=…
└─ set activities state
└─ onSubmit
└─ fetch POST /api/gforms/clause
└─ set clauseResult state → renders <ClauseOutput />
ClauseOutput (client)
└─ needsClause:false → Alert
└─ needsClause:true → consent section + info clause section, each with CopyButton
└─ if NEXT_PUBLIC_GOOGLE_CLIENT_ID → "Add to Google Form" section
└─ Google OAuth popup (forms.body scope)
└─ addItemsToGoogleForm(formIdOrUrl, accessToken, items)
googleFormsApi.ts — Client-Side
extractFormId(input) — regex against /forms/d/(?:e/)?([^/]+). Returns the captured group or input.trim() if no match. Handles both /edit and /viewform URL patterns.
addItemsToGoogleForm(formIdOrUrl, accessToken, items) — calls POST https://forms.googleapis.com/v1/forms/{formId}:batchUpdate with a createItem request per item, using location: { index: idx } to insert in order. On non-ok response, tries to parse error.message from the JSON body; falls back to HTTP {status}.
This function is called from the browser (inside ClauseOutput.tsx) using the OAuth access token obtained from the Google OAuth popup. It is not used by the Apps Script path — the Apps Script calls the Google Forms API directly via UrlFetchApp.
Translations
All GForms text — both UI labels and generated clause content — lives in messages/{locale}.json under the "gforms" namespace. All 10 supported locales (ca, en, es, de, fr, it, nl, pt, eu, gl) have full native-language translations.
The namespace has two categories of keys:
Clause content (inserted into the Google Form — must be legally accurate):
| Key | Purpose |
|---|---|
consentTitle | Title of the consent checkbox item |
consentEthnics … consentCommunication | One consent item per Art. 9 / transfer category |
infoClauseTitle | Title of the Art. 13 information checkbox |
infoClauseText | Art. 13 text; contains {url} placeholder |
UI labels (sidebar and web page labels — not inserted into the form):
| Key group | Keys |
|---|---|
| Questions | anonymousQuestion, indirectQuestion, specialCategories, internationalTransfer, communication, eeeNote |
| Category labels | ethnics, opinion, belief, biogen, health, sex |
| Selectors | selectOrg, orgPlaceholder, selectLanguage, selectActivity, selectActivityDefault |
| Feedback | loadingActivities, pendingOrgNote, generateButton, generatingButton, identifyActivityPrompt, doneText, noClauseNeeded |
| Clause output | consentItemsTitle, infoClauseSection, addToForm, formIdLabel, formIdPlaceholder, clauseAdded, clauseError |
| Misc | yes, no, pageTitle, collectsEmailNote |
The {url} placeholder in infoClauseText is replaced server-side in the clause route handler before calling generateClause — the pure function receives the already-interpolated string.
To update clause wording for a locale, edit the relevant keys in messages/{locale}.json. The sidebar picks up changes on the next sidebar open (the loadTranslations cache is process-scoped and resets on server restart).
Configuration
| Variable | Where | Effect |
|---|---|---|
NEXT_PUBLIC_GOOGLE_CLIENT_ID | .env | Enables the "Add to Google Form" button on the web page. If unset, ClauseOutput hides the section entirely. |
NEXT_PUBLIC_SITE_DOMAIN | .env | Domain used to build publicUrl for activities (default: rat.gd). |
ROPA_API_BASE in constants.gs | Apps Script | Base URL of the ROPA app that the sidebar calls. |
DefaultLocale in constants.gs | Apps Script | Locale shown when the sidebar first opens. |
Error Propagation Convention
Both routes follow the same pattern: helper functions return a discriminated union — either the success value or { error: string; status: number }. Routes check "error" in result and call Response.json({ error }, { status }) before returning. There are no thrown exceptions in the happy path; try/catch at the route level catches unexpected failures.
Known Limitations
- Activity filter:
getActivitiesForOrgLocalereturns both active and inactive activities (noactivefilter). The original Apps Script filtered to active only. The web page relies on the full list from the API. - Apps Script OAuth: the Apps Script uses
ScriptApp.getOAuthToken()directly; there is no shared token mechanism with the web page's Google OAuth popup. - Translation cache:
loadTranslationsuses a process-scoped in-memory cache. Updated translations are only picked up after a server restart (or redeploy). This is intentional for performance.
Testing
See GForms Add-on Tests for the full test plan.
Quick summary:
| Layer | Runner | Files |
|---|---|---|
| Tier 1 — pure functions | Vitest (node) | generateClause.test.ts, googleFormsApi.test.ts |
| Tier 2 — route handlers + auth | Vitest (node) | resolveGformsUser.test.ts, activities/route.test.ts, clause/route.test.ts |
| Tier 4 — React components | Vitest (jsdom) | GFormsForm.test.tsx, ClauseOutput.test.tsx |
The /api/gforms/locales/[locale] route has no dedicated test file — it is a thin wrapper around loadTranslations (already tested indirectly) with locale validation. Add a Tier 2 test if the validation logic grows.
The Apps Script files (GForms/) have no automated tests. The critical paths (auth resolution, clause generation, API contract) are exercised at Tiers 1–2.