Store Google Ads click ids with ad consent and pass them to the pricing page - #785
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #754. Sign-ups kept only
paid: truewhen 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
audit/signup-attribution.ts,sanitizeTouch):gclid,gbraidandwbraidare kept only on a touch that carriesad_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.auth/signup-attribution.dto.ts): accepts the three ids with the same charset and length rules, plusad_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.clickIdFromAttributionsanitizes the stored rows again, so a legacy or hand-edited row never returns an id without consent.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 themarketingcategory accepted (the same category that drives consent mode'sad_storageandad_user_data). Otherwise the touch is onlypaid, withad_consent: 'denied' | 'unknown'. If the cloud banner saysdenied, 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 andevent = '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 afterreturn_url. A newusePricingUrl()hook fetches it once per session, only whendeploymentMode === '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 withgranted, drops them for denied, unknown, missing or malformed consent, checks consent per touch, enforces charset and length, stays idempotent, andclickIdFromAttributionpicks ids correctly.auth/signup-attribution.spec.ts: runs through theValidationPipeconfigured as inmain.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.mdupdated.