Skip to main content

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)

FileRole
constants.gsROPA_API_BASE and DefaultLocale
ShowSidebar.gsOpens sidebar; fetches messages + activities on load; exposes loadMessages and loadActivities for locale changes
ProcessForm.gsReceives questionnaire answers; calls clause API; writes items to the form
Clausules_RGPD.htmlSidebar HTML/UI
Logging.gsSheets 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/)

FileRole
src/lib/gforms/generateClause.tsPure function — questionnaire → clause items
src/lib/gforms/resolveGformsUser.tsServer-only auth helper
src/lib/gforms/googleFormsApi.tsClient-side Google Forms REST API wrapper
src/app/api/gforms/activities/route.tsGET /api/gforms/activities
src/app/api/gforms/clause/route.tsPOST /api/gforms/clause
src/app/api/gforms/locales/[locale]/route.tsGET /api/gforms/locales/:locale — UI messages
src/app/[locale]/gforms/page.tsxClerk-protected web page (server component)
src/lib/ui/gforms/GFormsForm.tsxClient component — questionnaire form
src/lib/ui/gforms/ClauseOutput.tsxClient 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 shortName query 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 resolveGformsUser returns requiresOrgSelection: true at 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

StepWhat happens
1Parse locale, email, shortName from query params. Return 400 if locale is missing.
2Call resolveGformsUser(email). Propagate any error response.
3Resolve target org (see multi-org resolution above).
4Call getActivitiesForOrgLocale(shortName, locale). Propagate any error.
5Return { activities, shortName, locales, defaultLocale }.

getActivitiesForOrgLocale (in resolveGformsUser.ts):

  1. Fetch org by shortName → find the matching ROPA ref by locale → fetch that ROPA.
  2. Flatten all OUs → collect { activityId, activityName } pairs.
  3. Build publicUrl: https://<shortName>.<NEXT_PUBLIC_SITE_DOMAIN>/<locale>/<activityId> (uses http://…localhost:3000 in development).
  4. 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.locales with 400.
  • Implementation: calls loadTranslations(locale) (file-system read with in-memory cache) and returns translations.gforms.
  • Consumed by: ShowSidebar.gsfetchMessagesFromApi(locale) on open; loadMessages(locale) on locale change via google.script.run.

POST /api/gforms/clause

Source: src/app/api/gforms/clause/route.ts

StepWhat happens
1Parse JSON body. Return 400 on parse error or missing locale/activityId/formData.
2Call resolveGformsUser(email ?? null). Propagate any error response.
3Resolve target org.
4Call getActivitiesForOrgLocale to get the full activity list.
5Find the activity by numeric ID (coerces string IDs). Return 404 if not found.
6Load translations with getTranslations({ locale, namespace: "gforms" }).
7Call generateClause(formData, activity, translations).
8Return { ...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):

KeyPurpose
consentTitleTitle of the consent checkbox item
consentEthnicsconsentCommunicationOne consent item per Art. 9 / transfer category
infoClauseTitleTitle of the Art. 13 information checkbox
infoClauseTextArt. 13 text; contains {url} placeholder

UI labels (sidebar and web page labels — not inserted into the form):

Key groupKeys
QuestionsanonymousQuestion, indirectQuestion, specialCategories, internationalTransfer, communication, eeeNote
Category labelsethnics, opinion, belief, biogen, health, sex
SelectorsselectOrg, orgPlaceholder, selectLanguage, selectActivity, selectActivityDefault
FeedbackloadingActivities, pendingOrgNote, generateButton, generatingButton, identifyActivityPrompt, doneText, noClauseNeeded
Clause outputconsentItemsTitle, infoClauseSection, addToForm, formIdLabel, formIdPlaceholder, clauseAdded, clauseError
Miscyes, 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

VariableWhereEffect
NEXT_PUBLIC_GOOGLE_CLIENT_ID.envEnables the "Add to Google Form" button on the web page. If unset, ClauseOutput hides the section entirely.
NEXT_PUBLIC_SITE_DOMAIN.envDomain used to build publicUrl for activities (default: rat.gd).
ROPA_API_BASE in constants.gsApps ScriptBase URL of the ROPA app that the sidebar calls.
DefaultLocale in constants.gsApps ScriptLocale 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: getActivitiesForOrgLocale returns both active and inactive activities (no active filter). 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: loadTranslations uses 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:

LayerRunnerFiles
Tier 1 — pure functionsVitest (node)generateClause.test.ts, googleFormsApi.test.ts
Tier 2 — route handlers + authVitest (node)resolveGformsUser.test.ts, activities/route.test.ts, clause/route.test.ts
Tier 4 — React componentsVitest (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.