Skip to content

Store Google Ads click ids with ad consent and pass them to the pricing page - #785

Merged
keysersoft merged 1 commit into
mainfrom
keysersoft/cloud-click-ids
Sep 28, 2026
Merged

keysersoft merged 1 commit into
mainfrom
keysersoft/cloud-click-ids

Conversation

@keysersoft

Copy link
Copy Markdown
Contributor

Follow-up to #754. Sign-ups kept only paid: true when an ad click id was on the URL. To upload purchases to Google Ads as offline conversions (the upload runs on the marketing site's Stripe webhook), the cloud now stores the click id itself, but only when the visitor granted ad consent.

Consent rule: never a click id without consent

  • Backend (audit/signup-attribution.ts, sanitizeTouch): gclid, gbraid and wbraid are kept only on a touch that carries ad_consent: 'granted'. Otherwise they are dropped and the rest of the touch is kept. Every touch is checked on its own: consent on the first touch does not carry over to the last. Ids must match [A-Za-z0-9_-] and be at most 150 characters.
  • DTO (auth/signup-attribution.dto.ts): accepts the three ids with the same charset and length rules, plus ad_consent (granted / denied / unknown). A click id sent without consent passes validation but the sanitizer drops it. It is not a 400, so the sign-up still records the rest of the attribution.
  • Reading back: clickIdFromAttribution sanitizes the stored rows again, so a legacy or hand-edited row never returns an id without consent.
  • Cloud frontend (lib/attribution.ts): when the page builds its own touch from its URL, it keeps the id only if the cloud's cookie banner has the marketing category accepted (the same category that drives consent mode's ad_storage and ad_user_data). Otherwise the touch is only paid, with ad_consent: 'denied' | 'unknown'. If the cloud banner says denied, ids handed over by the site are dropped too. If a visitor lands from an ad and accepts marketing cookies before submitting, the click id is added as the last touch.

Endpoint

GET /api/auth/attribution/click-ids (JWT) returns the caller's own id, for example { "gclid": "…", "ad_consent": "granted", "captured_at": "…" }, and {} when there is none. It returns a single id: last touch before first, and gclid before gbraid before wbraid. The query uses the session's user id and event = 'signup_attributed', so the caller cannot pass anything that changes whose data is read. It is cloud only: self-hosted returns {} without querying.

Pricing links

buildPricingUrl(returnPath, clickIds) appends a well-formed id after return_url. A new usePricingUrl() hook fetches it once per session, only when deploymentMode === 'cloud'. It returns the plain link until the lookup answers or if it fails. The hook skips the auto-logout on a 401. It is used by settings/license, the licence wall and the trial banner. The pricing link on the login page belongs to the self-hosted license-key setup step, so it stays without a click id.

Tests

  • audit/signup-attribution.spec.ts: the sanitizer keeps ids only with granted, drops them for denied, unknown, missing or malformed consent, checks consent per touch, enforces charset and length, stays idempotent, and clickIdFromAttribution picks ids correctly.
  • auth/signup-attribution.spec.ts: runs through the ValidationPipe configured as in main.ts (accept and refuse cases, the 150-character boundary), then register stores the id with consent and drops every id without it.
  • audit/signup-attribution.controller.spec.ts: the route is guarded, returns only the caller's own data (and never another user's in the same org), ignores rows without consent and non-attribution events, returns nothing without a user id, and returns empty on self-hosted.

Docs: docs/operations/signup-attribution.md updated.

…hem to pricing

Sign-up attribution now accepts gclid, gbraid and wbraid ([A-Za-z0-9_-],
max 150) plus ad_consent on each touch. The server keeps a click id only
on a touch that says ad_consent = granted and drops it otherwise; stored
rows are re-sanitized on the way out as well.

The cloud sign-up page keeps a click id from its own URL only when its
cookie banner has the marketing category accepted, and drops handed-over
ids when that banner says no.

GET /api/auth/attribution/click-ids returns the signed-in user's own
click id from their signup_attributed event (cloud only, {} on
self-hosted). The cloud app appends it to its pricing links next to
return_url so the purchase can be uploaded to Google Ads as an offline
conversion.
@keysersoft
keysersoft merged commit faf0095 into main Sep 28, 2026
14 checks passed
@keysersoft
keysersoft deleted the keysersoft/cloud-click-ids branch September 28, 2026 09:25
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 28, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant