From 5439cc3ac2c1da48b11032574c6ce6ebe3cb8ea5 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 24 Jul 2026 17:24:43 +0800 Subject: [PATCH] Add campaign pageview tracking and /github redirect. Record raw marketing_page_views for repeat campaign visits (including UTM 404s), keep first-touch sessions intact, and ship a client /github redirect so GA and first-party tracking fire before leaving the site. Co-authored-by: Cursor --- app/(landing)/_components/landing-header.tsx | 2 +- .../page-view/route.ts | 26 + app/github/page.tsx | 35 + drizzle/0009_mushy_ezekiel.sql | 19 + drizzle/meta/0009_snapshot.json | 1098 +++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/acquisition-attribution/client.test.ts | 16 +- lib/acquisition-attribution/client.ts | 108 +- lib/acquisition-attribution/core.test.ts | 57 + lib/acquisition-attribution/core.ts | 89 +- lib/db/schema.ts | 31 + lib/landing-figma-design.test.ts | 4 +- .../acquisition-attribution-provider.tsx | 28 +- providers/providers.tsx | 5 +- server/acquisition-attribution.ts | 23 +- 15 files changed, 1523 insertions(+), 25 deletions(-) create mode 100644 app/api/acquisition-attribution/page-view/route.ts create mode 100644 app/github/page.tsx create mode 100644 drizzle/0009_mushy_ezekiel.sql create mode 100644 drizzle/meta/0009_snapshot.json diff --git a/app/(landing)/_components/landing-header.tsx b/app/(landing)/_components/landing-header.tsx index 2a3dacd..7f643f4 100644 --- a/app/(landing)/_components/landing-header.tsx +++ b/app/(landing)/_components/landing-header.tsx @@ -26,7 +26,7 @@ const landingNavItems: LandingNavItem[] = [ { href: "https://notebook.knowhereto.ai", labelKey: "playground", external: true }, { href: "#pricing", labelKey: "pricing" }, { href: "https://docs.knowhereto.ai/", labelKey: "docs", external: true }, - { href: "https://github.com/Ontos-AI/knowhere", labelKey: "github", external: true }, + { href: "/github", labelKey: "github" }, ]; const localeLabels = { diff --git a/app/api/acquisition-attribution/page-view/route.ts b/app/api/acquisition-attribution/page-view/route.ts new file mode 100644 index 0000000..7bd822e --- /dev/null +++ b/app/api/acquisition-attribution/page-view/route.ts @@ -0,0 +1,26 @@ +import { captureMarketingPageView } from "@server/acquisition-attribution"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +const pageViewRequestSchema = z.object({ + acquisitionSessionId: z.string().optional(), + landingUrl: z.url(), + referrer: z.string().optional(), +}); + +export async function POST(request: Request): Promise { + const requestBody = await request.json().catch(() => null); + const parsedRequest = pageViewRequestSchema.safeParse(requestBody); + + if (!parsedRequest.success) { + return NextResponse.json({ message: "Invalid acquisition payload" }, { status: 400 }); + } + + const result = await captureMarketingPageView({ + acquisitionSessionId: parsedRequest.data.acquisitionSessionId, + landingUrl: parsedRequest.data.landingUrl, + referrer: parsedRequest.data.referrer, + }); + + return NextResponse.json(result); +} diff --git a/app/github/page.tsx b/app/github/page.tsx new file mode 100644 index 0000000..080dc96 --- /dev/null +++ b/app/github/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Link from "next/link"; +import { useEffect } from "react"; + +const GITHUB_REPO_URL = "https://github.com/Ontos-AI/knowhere"; +const REDIRECT_DELAY_MS = 400; + +export default function GithubRedirectPage() { + useEffect(() => { + const timeoutId = window.setTimeout(() => { + window.location.replace(GITHUB_REPO_URL); + }, REDIRECT_DELAY_MS); + + return () => { + window.clearTimeout(timeoutId); + }; + }, []); + + return ( +
+

Redirecting to GitHub…

+

+ Taking you to the Knowhere repository. If nothing happens, use the link below. +

+ + Continue to github.com/Ontos-AI/knowhere + +
+ ); +} diff --git a/drizzle/0009_mushy_ezekiel.sql b/drizzle/0009_mushy_ezekiel.sql new file mode 100644 index 0000000..13cc074 --- /dev/null +++ b/drizzle/0009_mushy_ezekiel.sql @@ -0,0 +1,19 @@ +CREATE TABLE "marketing_page_views" ( + "view_id" text PRIMARY KEY NOT NULL, + "acquisition_session_id" text, + "source" text NOT NULL, + "channel" text NOT NULL, + "utm_source" text, + "utm_medium" text, + "utm_campaign" text, + "utm_content" text, + "utm_term" text, + "oppref" text, + "visited_path" text NOT NULL, + "referrer_host" text, + "viewed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE INDEX "marketingPageView_viewedAt_idx" ON "marketing_page_views" USING btree ("viewed_at");--> statement-breakpoint +CREATE INDEX "marketingPageView_visitedPath_viewedAt_idx" ON "marketing_page_views" USING btree ("visited_path","viewed_at");--> statement-breakpoint +CREATE INDEX "marketingPageView_acquisitionSessionId_idx" ON "marketing_page_views" USING btree ("acquisition_session_id"); \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..0d32956 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1098 @@ +{ + "id": "918af8cf-d54a-4b54-a5de-5cc326733c10", + "prevId": "5cd047a4-9347-4eaf-a3e5-f7b6533ff5ed", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emailVerificationToken": { + "name": "emailVerificationToken", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "emailVerificationToken_userId_user_id_fk": { + "name": "emailVerificationToken_userId_user_id_fk", + "tableFrom": "emailVerificationToken", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "emailVerificationToken_token_unique": { + "name": "emailVerificationToken_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "publicKey": { + "name": "publicKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privateKey": { + "name": "privateKey", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "usageWelcomeStatus": { + "name": "usageWelcomeStatus", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "usageWelcomeApiKey": { + "name": "usageWelcomeApiKey", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketing_attribution_sessions": { + "name": "marketing_attribution_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oppref": { + "name": "oppref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_host": { + "name": "referrer_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bound_user_id": { + "name": "bound_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bound_at": { + "name": "bound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "marketingAttributionSession_boundUserId_idx": { + "name": "marketingAttributionSession_boundUserId_idx", + "columns": [ + { + "expression": "bound_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingAttributionSession_capturedAt_idx": { + "name": "marketingAttributionSession_capturedAt_idx", + "columns": [ + { + "expression": "captured_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingAttributionSession_sourceCampaign_idx": { + "name": "marketingAttributionSession_sourceCampaign_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "utm_campaign", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "marketing_attribution_sessions_bound_user_id_user_id_fk": { + "name": "marketing_attribution_sessions_bound_user_id_user_id_fk", + "tableFrom": "marketing_attribution_sessions", + "tableTo": "user", + "columnsFrom": ["bound_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.marketing_page_views": { + "name": "marketing_page_views", + "schema": "", + "columns": { + "view_id": { + "name": "view_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "acquisition_session_id": { + "name": "acquisition_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oppref": { + "name": "oppref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visited_path": { + "name": "visited_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_host": { + "name": "referrer_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "marketingPageView_viewedAt_idx": { + "name": "marketingPageView_viewedAt_idx", + "columns": [ + { + "expression": "viewed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingPageView_visitedPath_viewedAt_idx": { + "name": "marketingPageView_visitedPath_viewedAt_idx", + "columns": [ + { + "expression": "visited_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "viewed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "marketingPageView_acquisitionSessionId_idx": { + "name": "marketingPageView_acquisitionSessionId_idx", + "columns": [ + { + "expression": "acquisition_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.newsletterSubscription": { + "name": "newsletterSubscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "confirmationTokenHash": { + "name": "confirmationTokenHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confirmationTokenExpiresAt": { + "name": "confirmationTokenExpiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmationSentAt": { + "name": "confirmationSentAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmedAt": { + "name": "confirmedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unsubscribedAt": { + "name": "unsubscribedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "newsletterSubscription_email_unique": { + "name": "newsletterSubscription_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletterSubscription_confirmationTokenHash_unique": { + "name": "newsletterSubscription_confirmationTokenHash_unique", + "columns": [ + { + "expression": "confirmationTokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "newsletterSubscription_status_idx": { + "name": "newsletterSubscription_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauthAuthorizationCode": { + "name": "oauthAuthorizationCode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeHash": { + "name": "codeHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirectUri": { + "name": "redirectUri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "codeChallenge": { + "name": "codeChallenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "clientName": { + "name": "clientName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full_access'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthAuthorizationCode_codeHash_unique": { + "name": "oauthAuthorizationCode_codeHash_unique", + "columns": [ + { + "expression": "codeHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthAuthorizationCode_userId_idx": { + "name": "oauthAuthorizationCode_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauthAuthorizationCode_userId_user_id_fk": { + "name": "oauthAuthorizationCode_userId_user_id_fk", + "tableFrom": "oauthAuthorizationCode", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauthRefreshToken": { + "name": "oauthRefreshToken", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full_access'" + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revokedAt": { + "name": "revokedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lastUsedAt": { + "name": "lastUsedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "oauthRefreshToken_tokenHash_unique": { + "name": "oauthRefreshToken_tokenHash_unique", + "columns": [ + { + "expression": "tokenHash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauthRefreshToken_userId_idx": { + "name": "oauthRefreshToken_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauthRefreshToken_userId_user_id_fk": { + "name": "oauthRefreshToken_userId_user_id_fk", + "tableFrom": "oauthRefreshToken", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e8713e5..5e055ea 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1784811344172, "tag": "0008_noisy_chimera", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1784879812653, + "tag": "0009_mushy_ezekiel", + "breakpoints": true } ] } diff --git a/lib/acquisition-attribution/client.test.ts b/lib/acquisition-attribution/client.test.ts index 41ac111..8e359ef 100644 --- a/lib/acquisition-attribution/client.test.ts +++ b/lib/acquisition-attribution/client.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { shouldCaptureAcquisitionPath } from "@/lib/acquisition-attribution/client"; +import { + hasCampaignQueryParams, + shouldCaptureAcquisitionPath, +} from "@/lib/acquisition-attribution/client"; describe("acquisition attribution client", () => { it("captures public landing routes", () => { @@ -7,12 +10,21 @@ describe("acquisition attribution client", () => { expect(shouldCaptureAcquisitionPath("/claw")).toBe(true); expect(shouldCaptureAcquisitionPath("/comparison/openai")).toBe(true); expect(shouldCaptureAcquisitionPath("/versus/chatgpt")).toBe(true); + expect(shouldCaptureAcquisitionPath("/github")).toBe(true); }); - it("does not count auth or dashboard routes as landing sessions", () => { + it("captures unknown public paths when campaign query params are present", () => { + expect(shouldCaptureAcquisitionPath("/reddit", "utm_source=reddit")).toBe(true); + expect(shouldCaptureAcquisitionPath("/campaign", "utm_medium=cpc")).toBe(true); + expect(shouldCaptureAcquisitionPath("/ads", "oppref=click_1")).toBe(true); + expect(hasCampaignQueryParams("?utm_campaign=launch")).toBe(true); + }); + + it("does not count auth or dashboard routes as landing sessions without campaign params", () => { expect(shouldCaptureAcquisitionPath("/register")).toBe(false); expect(shouldCaptureAcquisitionPath("/login")).toBe(false); expect(shouldCaptureAcquisitionPath("/forgot-password")).toBe(false); expect(shouldCaptureAcquisitionPath("/usage")).toBe(false); + expect(shouldCaptureAcquisitionPath("/reddit")).toBe(false); }); }); diff --git a/lib/acquisition-attribution/client.ts b/lib/acquisition-attribution/client.ts index f737a13..585a13b 100644 --- a/lib/acquisition-attribution/client.ts +++ b/lib/acquisition-attribution/client.ts @@ -3,30 +3,73 @@ type CaptureAcquisitionSessionRequest = { readonly referrer?: string; }; +type CaptureMarketingPageViewRequest = { + readonly acquisitionSessionId?: string | null; + readonly landingUrl: string; + readonly referrer?: string; +}; + type BindAcquisitionSessionRequest = { readonly userId: string; }; +type AcquisitionSessionCaptureResponse = { + readonly sessionId: string | null; +}; + +type MarketingPageViewCaptureResponse = { + readonly viewId: string | null; + readonly acquisitionSessionId: string | null; +}; + const CAPTURE_LANDING_PATH_PREFIXES = ["/claw", "/comparison", "/versus"] as const; +const CAPTURE_EXPLICIT_PATHS = new Set(["/", "/github"]); +const CAMPAIGN_QUERY_KEYS = [ + "utm_source", + "utm_medium", + "utm_campaign", + "utm_content", + "utm_term", + "oppref", +] as const; -export function shouldCaptureAcquisitionPath(pathname: string): boolean { - if (pathname === "/") { - return true; +export function hasCampaignQueryParams(search: string): boolean { + const normalizedSearch = search.startsWith("?") ? search.slice(1) : search; + if (!normalizedSearch) { + return false; } - return CAPTURE_LANDING_PATH_PREFIXES.some((prefix: string): boolean => { - return pathname === prefix || pathname.startsWith(`${prefix}/`); + const searchParams = new URLSearchParams(normalizedSearch); + return CAMPAIGN_QUERY_KEYS.some((key) => { + const value = searchParams.get(key); + return Boolean(value?.trim()); }); } +export function shouldCaptureAcquisitionPath(pathname: string, search = ""): boolean { + if (CAPTURE_EXPLICIT_PATHS.has(pathname)) { + return true; + } + + if ( + CAPTURE_LANDING_PATH_PREFIXES.some((prefix: string): boolean => { + return pathname === prefix || pathname.startsWith(`${prefix}/`); + }) + ) { + return true; + } + + return hasCampaignQueryParams(search); +} + export async function requestAcquisitionSessionCapture( input: CaptureAcquisitionSessionRequest -): Promise { +): Promise { if (typeof window === "undefined") { - return; + return { sessionId: null }; } - await fetch("/api/acquisition-attribution/session", { + const response = await fetch("/api/acquisition-attribution/session", { body: JSON.stringify(input), credentials: "same-origin", headers: { @@ -35,6 +78,55 @@ export async function requestAcquisitionSessionCapture( keepalive: true, method: "POST", }); + + if (!response.ok) { + return { sessionId: null }; + } + + const payload = (await response.json().catch(() => null)) as { + sessionId?: string | null; + } | null; + + return { + sessionId: typeof payload?.sessionId === "string" ? payload.sessionId : null, + }; +} + +export async function requestMarketingPageViewCapture( + input: CaptureMarketingPageViewRequest +): Promise { + if (typeof window === "undefined") { + return { viewId: null, acquisitionSessionId: null }; + } + + const response = await fetch("/api/acquisition-attribution/page-view", { + body: JSON.stringify({ + acquisitionSessionId: input.acquisitionSessionId ?? undefined, + landingUrl: input.landingUrl, + referrer: input.referrer, + }), + credentials: "same-origin", + headers: { + "content-type": "application/json", + }, + keepalive: true, + method: "POST", + }); + + if (!response.ok) { + return { viewId: null, acquisitionSessionId: null }; + } + + const payload = (await response.json().catch(() => null)) as { + viewId?: string | null; + acquisitionSessionId?: string | null; + } | null; + + return { + viewId: typeof payload?.viewId === "string" ? payload.viewId : null, + acquisitionSessionId: + typeof payload?.acquisitionSessionId === "string" ? payload.acquisitionSessionId : null, + }; } export async function requestAcquisitionSessionBind( diff --git a/lib/acquisition-attribution/core.test.ts b/lib/acquisition-attribution/core.test.ts index 5e7857b..98b57cb 100644 --- a/lib/acquisition-attribution/core.test.ts +++ b/lib/acquisition-attribution/core.test.ts @@ -3,9 +3,11 @@ import { type AcquisitionAttributionRepository, type AcquisitionAttributionSessionInsert, createAcquisitionAttributionService, + type MarketingPageViewInsert, } from "@/lib/acquisition-attribution/core"; function createMemoryRepository(): { + readonly pageViews: MarketingPageViewInsert[]; readonly repository: AcquisitionAttributionRepository; readonly sessions: Map< string, @@ -16,8 +18,10 @@ function createMemoryRepository(): { string, AcquisitionAttributionSessionInsert & { boundUserId: string | null } >(); + const pageViews: MarketingPageViewInsert[] = []; return { + pageViews, repository: { bindSessionToUser: async ({ boundAt: _boundAt, sessionId, userId }): Promise => { const session = sessions.get(sessionId); @@ -43,6 +47,10 @@ function createMemoryRepository(): { sessionId: session.sessionId, }; }, + insertPageView: async (pageView: MarketingPageViewInsert): Promise => { + pageViews.push(pageView); + return true; + }, insertSession: async (session: AcquisitionAttributionSessionInsert): Promise => { if (sessions.has(session.sessionId)) { return false; @@ -64,6 +72,7 @@ describe("acquisition attribution", () => { const { repository, sessions } = createMemoryRepository(); const service = createAcquisitionAttributionService({ createSessionId: (): string => "session_first", + createViewId: (): string => "view_unused", getNow: (): Date => new Date("2026-07-23T00:00:00.000Z"), repository, }); @@ -102,6 +111,7 @@ describe("acquisition attribution", () => { const { repository, sessions } = createMemoryRepository(); const service = createAcquisitionAttributionService({ createSessionId: (): string => "session_signup", + createViewId: (): string => "view_unused", getNow: (): Date => new Date("2026-07-23T00:00:00.000Z"), repository, }); @@ -127,4 +137,51 @@ describe("acquisition attribution", () => { }); expect(sessions.get("session_signup")?.boundUserId).toBe("user_123"); }); + + it("records raw pageviews even when the first-touch session already exists", async () => { + const { pageViews, repository } = createMemoryRepository(); + let viewCounter = 0; + const service = createAcquisitionAttributionService({ + createSessionId: (): string => "session_first", + createViewId: (): string => `view_${++viewCounter}`, + getNow: (): Date => new Date("2026-07-23T00:00:00.000Z"), + repository, + }); + + await service.captureAcquisitionSession({ + landingUrl: "https://knowhereto.ai/?utm_source=reddit", + }); + + const firstPageView = await service.captureMarketingPageView({ + acquisitionSessionId: "session_first", + landingUrl: "https://knowhereto.ai/reddit?utm_source=reddit&utm_campaign=launch", + referrer: "https://reddit.com/r/something", + }); + const secondPageView = await service.captureMarketingPageView({ + acquisitionSessionId: "session_first", + landingUrl: "https://knowhereto.ai/reddit?utm_source=reddit&utm_campaign=launch", + referrer: "https://reddit.com/r/something", + }); + + expect(firstPageView).toEqual({ + captured: true, + viewId: "view_1", + acquisitionSessionId: "session_first", + }); + expect(secondPageView).toEqual({ + captured: true, + viewId: "view_2", + acquisitionSessionId: "session_first", + }); + expect(pageViews).toHaveLength(2); + expect(pageViews[0]).toMatchObject({ + acquisitionSessionId: "session_first", + channel: "referral", + source: "reddit", + utmCampaign: "launch", + utmSource: "reddit", + visitedPath: "/reddit", + referrerHost: "reddit.com", + }); + }); }); diff --git a/lib/acquisition-attribution/core.ts b/lib/acquisition-attribution/core.ts index cd06b7d..060332a 100644 --- a/lib/acquisition-attribution/core.ts +++ b/lib/acquisition-attribution/core.ts @@ -17,6 +17,12 @@ export type AcquisitionBindInput = { readonly userId: string; }; +export type MarketingPageViewCaptureInput = { + readonly acquisitionSessionId?: string | null; + readonly landingUrl: string; + readonly referrer?: string | null; +}; + export type AcquisitionAttributionSessionInsert = { readonly sessionId: string; readonly source: string; @@ -32,6 +38,22 @@ export type AcquisitionAttributionSessionInsert = { readonly capturedAt: Date; }; +export type MarketingPageViewInsert = { + readonly viewId: string; + readonly acquisitionSessionId: string | null; + readonly source: string; + readonly channel: string; + readonly utmSource: string | null; + readonly utmMedium: string | null; + readonly utmCampaign: string | null; + readonly utmContent: string | null; + readonly utmTerm: string | null; + readonly oppref: string | null; + readonly visitedPath: string; + readonly referrerHost: string | null; + readonly viewedAt: Date; +}; + export type AcquisitionAttributionStoredSession = { readonly sessionId: string; readonly boundUserId: string | null; @@ -48,6 +70,7 @@ export type AcquisitionAttributionRepository = { sessionId: string ) => Promise; readonly insertSession: (session: AcquisitionAttributionSessionInsert) => Promise; + readonly insertPageView: (pageView: MarketingPageViewInsert) => Promise; }; export type AcquisitionCaptureResult = @@ -77,6 +100,19 @@ export type AcquisitionBindResult = readonly sessionId: string; }; +export type MarketingPageViewCaptureResult = + | { + readonly captured: false; + readonly reason: "invalid_landing_url"; + readonly viewId: null; + readonly acquisitionSessionId: null; + } + | { + readonly captured: true; + readonly viewId: string; + readonly acquisitionSessionId: string | null; + }; + export type AcquisitionAttributionService = { readonly bindAcquisitionSessionToUser: ( input: AcquisitionBindInput @@ -84,10 +120,14 @@ export type AcquisitionAttributionService = { readonly captureAcquisitionSession: ( input: AcquisitionCaptureInput ) => Promise; + readonly captureMarketingPageView: ( + input: MarketingPageViewCaptureInput + ) => Promise; }; type AcquisitionAttributionServiceOptions = { readonly createSessionId: () => string; + readonly createViewId: () => string; readonly getNow: () => Date; readonly repository: AcquisitionAttributionRepository; }; @@ -207,7 +247,7 @@ function deriveChannel({ } function normalizeCaptureInput( - input: AcquisitionCaptureInput + input: AcquisitionCaptureInput | MarketingPageViewCaptureInput ): NormalizedAcquisitionCapture | null { const parsedLandingUrl = parseHttpUrl(input.landingUrl); if (!parsedLandingUrl) { @@ -241,6 +281,7 @@ function normalizeCaptureInput( export function createAcquisitionAttributionService({ createSessionId, + createViewId, getNow, repository, }: AcquisitionAttributionServiceOptions): AcquisitionAttributionService { @@ -329,5 +370,51 @@ export function createAcquisitionAttributionService({ sessionId, }; }, + captureMarketingPageView: async ( + input: MarketingPageViewCaptureInput + ): Promise => { + const capture = normalizeCaptureInput(input); + if (!capture) { + return { + captured: false, + reason: "invalid_landing_url", + viewId: null, + acquisitionSessionId: null, + }; + } + + const acquisitionSessionId = normalizeSessionId(input.acquisitionSessionId); + const viewId = createViewId(); + const didInsert = await repository.insertPageView({ + acquisitionSessionId, + channel: capture.channel, + oppref: capture.oppref, + referrerHost: capture.referrerHost, + source: capture.source, + utmCampaign: capture.utmCampaign, + utmContent: capture.utmContent, + utmMedium: capture.utmMedium, + utmSource: capture.utmSource, + utmTerm: capture.utmTerm, + viewId, + viewedAt: getNow(), + visitedPath: capture.landingPath, + }); + + if (!didInsert) { + return { + captured: false, + reason: "invalid_landing_url", + viewId: null, + acquisitionSessionId: null, + }; + } + + return { + captured: true, + viewId, + acquisitionSessionId, + }; + }, }; } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 80acfa5..a65e438 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -80,3 +80,34 @@ export const marketingAttributionSession = pgTable( ), }) ); + +export const marketingPageView = pgTable( + "marketing_page_views", + { + viewId: text("view_id") + .primaryKey() + .$defaultFn(() => createId()), + acquisitionSessionId: text("acquisition_session_id"), + source: text("source").notNull(), + channel: text("channel").notNull(), + utmSource: text("utm_source"), + utmMedium: text("utm_medium"), + utmCampaign: text("utm_campaign"), + utmContent: text("utm_content"), + utmTerm: text("utm_term"), + oppref: text("oppref"), + visitedPath: text("visited_path").notNull(), + referrerHost: text("referrer_host"), + viewedAt: timestamp("viewed_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + viewedAtIndex: index("marketingPageView_viewedAt_idx").on(table.viewedAt), + visitedPathViewedAtIndex: index("marketingPageView_visitedPath_viewedAt_idx").on( + table.visitedPath, + table.viewedAt + ), + acquisitionSessionIdIndex: index("marketingPageView_acquisitionSessionId_idx").on( + table.acquisitionSessionId + ), + }) +); diff --git a/lib/landing-figma-design.test.ts b/lib/landing-figma-design.test.ts index a3fe1b0..2444afa 100644 --- a/lib/landing-figma-design.test.ts +++ b/lib/landing-figma-design.test.ts @@ -22,9 +22,7 @@ describe("landing contracts", () => { expect(landingHeaderSource).toContain( '{ href: "https://notebook.knowhereto.ai", labelKey: "playground", external: true }' ); - expect(landingHeaderSource).toContain( - '{ href: "https://github.com/Ontos-AI/knowhere", labelKey: "github", external: true }' - ); + expect(landingHeaderSource).toContain('{ href: "/github", labelKey: "github" }'); }); it("keeps the playground sample area annotated with the drag-to-parse cue", () => { diff --git a/providers/acquisition-attribution-provider.tsx b/providers/acquisition-attribution-provider.tsx index 0412d09..5678e1e 100644 --- a/providers/acquisition-attribution-provider.tsx +++ b/providers/acquisition-attribution-provider.tsx @@ -2,6 +2,7 @@ import { requestAcquisitionSessionCapture, + requestMarketingPageViewCapture, shouldCaptureAcquisitionPath, } from "@lib/acquisition-attribution/client"; import { usePathname, useSearchParams } from "next/navigation"; @@ -18,7 +19,7 @@ export function AcquisitionAttributionProvider() { return; } - if (!shouldCaptureAcquisitionPath(pathname)) { + if (!shouldCaptureAcquisitionPath(pathname, search)) { return; } @@ -28,13 +29,24 @@ export function AcquisitionAttributionProvider() { } capturedLandingUrlRef.current = landingUrl; - - void requestAcquisitionSessionCapture({ - landingUrl, - referrer: document.referrer || undefined, - }).catch((error: unknown): void => { - console.error("Failed to capture acquisition attribution session:", error); - }); + const referrer = document.referrer || undefined; + + void (async (): Promise => { + try { + const sessionCapture = await requestAcquisitionSessionCapture({ + landingUrl, + referrer, + }); + + await requestMarketingPageViewCapture({ + acquisitionSessionId: sessionCapture.sessionId, + landingUrl, + referrer, + }); + } catch (error: unknown) { + console.error("Failed to capture acquisition attribution:", error); + } + })(); }, [pathname, search]); return null; diff --git a/providers/providers.tsx b/providers/providers.tsx index 0c18f84..35ac377 100644 --- a/providers/providers.tsx +++ b/providers/providers.tsx @@ -7,12 +7,15 @@ import { AuthenticatedJobAnalyticsSync } from "@providers/authenticated-job-anal import { QueryProvider } from "@providers/query-provider"; import { TimezoneSync } from "@providers/timezone-sync"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { Suspense } from "react"; export function Providers({ children }: { children: React.ReactNode }) { const content = ( - + + + {children} diff --git a/server/acquisition-attribution.ts b/server/acquisition-attribution.ts index 6291542..e83ffad 100644 --- a/server/acquisition-attribution.ts +++ b/server/acquisition-attribution.ts @@ -1,5 +1,5 @@ import { db } from "@lib/db"; -import { marketingAttributionSession } from "@lib/db/schema"; +import { marketingAttributionSession, marketingPageView } from "@lib/db/schema"; import { createId } from "@paralleldrive/cuid2"; import { and, eq, isNull } from "drizzle-orm"; import { @@ -11,6 +11,9 @@ import { type AcquisitionCaptureInput, type AcquisitionCaptureResult, createAcquisitionAttributionService, + type MarketingPageViewCaptureInput, + type MarketingPageViewCaptureResult, + type MarketingPageViewInsert, } from "@/lib/acquisition-attribution/core"; const repository: AcquisitionAttributionRepository = { @@ -47,6 +50,17 @@ const repository: AcquisitionAttributionRepository = { return session ?? null; }, + insertPageView: async (pageView: MarketingPageViewInsert): Promise => { + const [insertedPageView] = await db + .insert(marketingPageView) + .values(pageView) + .onConflictDoNothing() + .returning({ + viewId: marketingPageView.viewId, + }); + + return Boolean(insertedPageView); + }, insertSession: async (session: AcquisitionAttributionSessionInsert): Promise => { const [insertedSession] = await db .insert(marketingAttributionSession) @@ -62,6 +76,7 @@ const repository: AcquisitionAttributionRepository = { const service = createAcquisitionAttributionService({ createSessionId: createId, + createViewId: createId, getNow: (): Date => new Date(), repository, }); @@ -77,3 +92,9 @@ export function bindAcquisitionSessionToUser( ): Promise { return service.bindAcquisitionSessionToUser(input); } + +export function captureMarketingPageView( + input: MarketingPageViewCaptureInput +): Promise { + return service.captureMarketingPageView(input); +}