Skip to content

Generated Links: Marketing-Attribution System

Before Generated Links, attributing a new user to a marketing campaign meant a manual CSV backfill after the fact, or, on Telegram, a base64url-encoded JSON payload validated against a config-file allowlist of FSP slugs and campaign codes [@design-doc]. Neither approach gave marketing a live count of clicks versus conversions, and neither worked for the PWA at all, which had no acquisition-link primitive [@design-doc]. Generated Links replaces both with one Filament-managed registry: marketing picks a channel (PWA or Telegram) and a set of pre-fill attributes, gets back a short URL, and the system records every click, stamps the pre-fill attributes onto whichever user that click eventually converts to, and fires Mixpanel events for both halves of the funnel [@design-doc].

A GeneratedLink carries a random six-to-twelve character slug (and an optional human-readable human_slug), a channel, and a set of pre-fill fields — fsp_id, user_type_id, gender, locale, latitude/longitude, marketing_ad_id, and a free-form custom_metadata JSON bag — plus is_active/expires_at for disabling a campaign and atomic clicks_count/registrations_count counters [@design-doc]. Every click against a link writes a GeneratedLinkClick audit row recording the channel, timestamp, IP, user agent, and — once the click converts — a resolved_user_id [@design-doc]. The users table itself gained four columns written once at registration if a Generated Link drove it: user_type_id, marketing_ad_id, custom_metadata, and source_generated_link_id, on a first-link-wins basis, so a returning user who clicks a second link never has their original attribution overwritten [@design-doc].

GeneratedLinkService: the one place both channels funnel through

App\Services\GeneratedLinks\GeneratedLinkService is the shared logic both the PWA and Telegram flows call into, so attribution behavior can't drift between channels [@service]. findBySlug() matches either the random slug or the optional human_slug [@service]. recordClick() looks up the link, writes a GeneratedLinkClick row (flagging was_inactive_at_click if the link is disabled or expired), atomically increments clicks_count with DB::raw('clicks_count + 1') only when the link was active, and fires either a generated_link_clicked or generated_link_clicked_inactive Mixpanel event so funnel dashboards don't count clicks against dead campaigns as conversions [@service]. applyToNewUser() is guarded to be a no-op if the user already has a source_generated_link_id, enforcing first-link-wins at the write site rather than trusting callers to check first; it force-fills the four denormalized columns unconditionally, but only fills fsp_id, gender, locale, latitude, and longitude when the user doesn't already have a value for that field [@service]. markClickResolved() stamps resolved_user_id on the click row, atomically increments registrations_count, and fires user_registered_via_generated_link [@service].

Where the code diverges from the original design

The design doc describes a GeneratedLinkClickMiddleware sitting in front of the Laravel web app: it would intercept any request carrying a ?link= query parameter, call recordClick(), and set an HttpOnly cookie the eventual registration request would read back to apply the link [@design-doc]. That is not what shipped. The actual entry point is a public API endpoint, POST /api/generated-links/clicks, handled by GeneratedLinkClickController::store() — its own docblock explains why: it exists specifically because "the PWA is served from a different origin than Laravel," so a same-origin, server-set cookie from a Laravel middleware would never reach the frontend at all [@click-controller]. The PWA calls this endpoint itself on boot whenever it detects ?link= in its own URL, passing the slug and an optional channel (defaulting to ChannelTypeEnum::Pwa), and the controller just forwards those values into GeneratedLinkService::recordClick() [@click-controller]. Any cookie-based hand-off between the click and the eventual registration call, if it exists, now lives entirely in the frontend rather than in a Laravel middleware — this page treats the controller as the source of truth for what actually ships, per the divergence between design intent and shipped code.

The Telegram flow

On Telegram, a /start payload is decoded by StartPayloadParser before StartHandler ever runs — a payload matching the short slug pattern is looked up as a Generated Link first, and only a payload that isn't a known slug falls through to the legacy base64url-JSON parsing path, so existing manually-crafted deep links keep working [@design-doc]. StartHandler itself writes a separate audit row to telegram_start_payloads on every /start, independent of whether a Generated Link matched — that table captures raw/decoded Telegram-specific payload pairs, while generated_link_clicks is channel-agnostic and link-centric, so the two audit trails intentionally aren't merged [@design-doc] [@start-handler]. This whole system has a hard prerequisite on the Telegram Mini-App work described in Telegram Bot Architecture: it reuses ChannelTypeEnum::Telegram, StartPayloadParser, and the telegram_start_payloads table that epic introduced [@design-doc].

Filament administration

Marketing manages links entirely inside the Filament admin panel, under app/Filament/Resources/GeneratedLinks/: full CRUD on the link record (label, channel, FSP, pre-fill fields, active/expiry state), a ClicksRelationManager listing the raw click audit trail per link, and a RegisteredUsersRelationManager listing every user whose source_generated_link_id points at that link [@filament-resources]. This sits alongside the rest of Filament's data-management surface described in Admin Panel, and the FSP pre-fill field ties directly into the multi-tenancy model covered in FSP.