From b04eec0a194b6515356be3958ebe0a7938174939 Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Thu, 16 Jul 2026 20:40:03 +0800 Subject: [PATCH] feat: measure landing campaign conversions --- .../onboarding-kickoff-contract.md | 8 ++ .../__tests__/landing-campaigns-start.test.ts | 14 +++- .../__tests__/scan-campaign-export.test.ts | 13 +++- .../__tests__/scan-fix-idempotency.test.ts | 45 +++++++++++ .../src/api/internal/scan-campaign-exports.ts | 7 +- packages/server/src/api/me.ts | 16 ++++ packages/server/src/api/orgs/chats.ts | 21 +++++- .../services/landing-campaigns/metadata.ts | 6 ++ .../src/services/landing-campaigns/start.ts | 3 + .../server/src/services/onboarding-kickoff.ts | 74 ++++++++++++++++++- .../__tests__/landing-campaign-schema.test.ts | 5 ++ packages/shared/src/index.ts | 4 + .../shared/src/schemas/landing-campaign.ts | 24 ++++++ .../pages/quickstart/__tests__/intent.test.ts | 35 ++++++++- .../__tests__/quickstart-page.e2e.test.tsx | 6 +- packages/web/src/pages/quickstart/intent.ts | 40 +++++++++- .../src/pages/quickstart/quickstart-page.tsx | 1 + 17 files changed, 304 insertions(+), 18 deletions(-) diff --git a/docs/development/onboarding-kickoff-contract.md b/docs/development/onboarding-kickoff-contract.md index 621bf9779..17fc9674e 100644 --- a/docs/development/onboarding-kickoff-contract.md +++ b/docs/development/onboarding-kickoff-contract.md @@ -16,6 +16,10 @@ chats and the adjacent campaign quickstart handoff. guardrail, and wakes the agent from visible task text. The campaign skill is not server-materialized: the kickoff message instructs the trial agent to clone the campaign's skill repo and run the named skill on the connected repo. +- Campaign quickstart may carry an anonymous `{ attemptId, variant }` + attribution pair. The pair is stored only on the trial chat's JSON metadata + and included in the internal campaign export; it does not change trial + idempotency, quota, or runtime behavior and requires no schema migration. - A `/me/onboarding/kickoff` request carrying `campaign` is a stale quickstart request. It must not create an onboarding kickoff chat or campaign idempotency key; it returns `410 campaign_kickoff_moved` when landing campaigns are enabled @@ -41,6 +45,10 @@ chats and the adjacent campaign quickstart handoff. legacy input that normalizes to `{ campaign: "production-scan", repoSlug }`; requests must not send both fields. An onboarding action still stamps completion like any onboarding kickoff. +- A successfully created campaign action chat is best-effort recorded on the + caller's matching trial-chat metadata. This keeps conversion measurement + inside the existing trial-export authorization boundary; ordinary action + chat content is never added to the cross-tenant export. - Campaign action fields belong only to the signed-in Web DTO (`CreateWebTaskChat`). The agent SDK's `CreateTaskChat` type and `/api/v1/agent/chats` contract do not expose them; a raw agent request that diff --git a/packages/server/src/__tests__/landing-campaigns-start.test.ts b/packages/server/src/__tests__/landing-campaigns-start.test.ts index 10de2801f..214609bac 100644 --- a/packages/server/src/__tests__/landing-campaigns-start.test.ts +++ b/packages/server/src/__tests__/landing-campaigns-start.test.ts @@ -1,4 +1,5 @@ import { + type LandingCampaignAttribution, MESSAGE_FORMATS, parseLandingCampaignTrialAgentMetadata, parseLandingCampaignTrialChatMetadata, @@ -110,8 +111,9 @@ async function startProductionScan( app: ReturnType>, admin: Awaited>, repoUrl = "https://github.com/acme/backend", + attribution?: LandingCampaignAttribution, ) { - return startCampaign(app, admin, "production-scan", repoUrl); + return startCampaign(app, admin, "production-scan", repoUrl, attribution); } async function startProductionScanInOrg( @@ -128,8 +130,9 @@ async function startCampaign( admin: Awaited>, campaign: string, repoUrl = "https://github.com/acme/backend", + attribution?: LandingCampaignAttribution, ) { - return startCampaignInOrg(app, admin, admin.organizationId, campaign, repoUrl); + return startCampaignInOrg(app, admin, admin.organizationId, campaign, repoUrl, attribution); } async function startCampaignInOrg( @@ -138,12 +141,13 @@ async function startCampaignInOrg( organizationId: string, campaign: string, repoUrl = "https://github.com/acme/backend", + attribution?: LandingCampaignAttribution, ) { return app.inject({ method: "POST", url: START_URL, headers: { authorization: `Bearer ${admin.accessToken}` }, - payload: { organizationId, campaign, repoUrl }, + payload: { organizationId, campaign, repoUrl, ...(attribution ? { attribution } : {}) }, }); } @@ -480,7 +484,8 @@ describe("POST /me/landing-campaigns/start", () => { const admin = await createTestAdmin(app); await seedOfficialRuntime(app, admin.organizationId); - const res = await startProductionScan(app, admin); + const attribution = { attemptId: crypto.randomUUID(), variant: "control" }; + const res = await startProductionScan(app, admin, "https://github.com/acme/backend", attribution); expect(res.statusCode).toBe(200); const body = res.json<{ chatId: string; agentUuid: string; campaign: string; repoCanonicalKey: string }>(); @@ -576,6 +581,7 @@ describe("POST /me/landing-campaigns/start", () => { campaign: "production-scan", agentId: body.agentUuid, repo: { canonicalKey: "github.com/acme/backend" }, + attribution, state: "running", inputLocked: false, maxAgentTurns: 1, diff --git a/packages/server/src/__tests__/scan-campaign-export.test.ts b/packages/server/src/__tests__/scan-campaign-export.test.ts index c15149c7e..fd736bc87 100644 --- a/packages/server/src/__tests__/scan-campaign-export.test.ts +++ b/packages/server/src/__tests__/scan-campaign-export.test.ts @@ -110,6 +110,7 @@ describe("scan campaign export routes", () => { const chatCreatedAt = new Date("2026-07-10T10:00:00.000Z"); const humanMessageAt = new Date("2026-07-10T10:01:30.000Z"); const agentMessageAt = new Date("2026-07-10T10:05:00.000Z"); + const attemptId = crypto.randomUUID(); await app.db .update(agents) @@ -134,6 +135,8 @@ describe("scan campaign export routes", () => { skillSetId: "production-scan", skillSetVersion: "test", repo: { url: "https://github.com/acme/api", canonicalKey: "github.com/acme/api" }, + attribution: { attemptId, variant: "control" }, + actionConversion: { chatId: "action-chat-1", recordedAt: "2026-07-10T10:06:00.000Z" }, state: "completed", inputLocked: true, maxAgentTurns: 2, @@ -238,11 +241,11 @@ describe("scan campaign export routes", () => { }, ]); - return { app, admin, agent, agentName, chatId }; + return { app, admin, agent, agentName, chatId, attemptId }; } it("exports trial, summary, and redacted message NDJSON for a client campaign", async () => { - const { app, admin, agent, agentName, chatId } = await seedTrialHistory(); + const { app, admin, agent, agentName, chatId, attemptId } = await seedTrialHistory(); const create = await app.inject({ method: "POST", @@ -260,6 +263,7 @@ describe("scan campaign export routes", () => { expect(create.statusCode).toBe(200); const body = create.json(); + expect(body.manifest.schemaVersion).toBe(2); expect(body.manifest.counts).toEqual({ trials: 1, chats: 1, messages: 2, exportedMessages: 2 }); const trials = body.files["trials.ndjson"].trim().split("\n").map(JSON.parse); const summaries = body.files["summaries.ndjson"].trim().split("\n").map(JSON.parse); @@ -280,6 +284,11 @@ describe("scan campaign export routes", () => { firstHumanResponseSeconds: 90, durationSeconds: 300, hasLikelyReportLink: true, + attemptId, + variant: "control", + actionConverted: true, + actionChatId: "action-chat-1", + actionRecordedAt: "2026-07-10T10:06:00.000Z", }); expect(exportedMessages[0]).toMatchObject({ chatId, diff --git a/packages/server/src/__tests__/scan-fix-idempotency.test.ts b/packages/server/src/__tests__/scan-fix-idempotency.test.ts index 789a138ec..e732e60bc 100644 --- a/packages/server/src/__tests__/scan-fix-idempotency.test.ts +++ b/packages/server/src/__tests__/scan-fix-idempotency.test.ts @@ -1,10 +1,13 @@ import crypto from "node:crypto"; +import { parseLandingCampaignTrialChatMetadata } from "@first-tree/shared"; import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { chats } from "../db/schema/chats.js"; import { clients } from "../db/schema/clients.js"; import { messages } from "../db/schema/messages.js"; import { createAgent } from "../services/agent.js"; +import { buildLandingCampaignChatMetadata } from "../services/landing-campaigns/metadata.js"; +import { createMeChat } from "../services/me-chat.js"; import { createTestAdmin, useTestApp } from "./helpers.js"; /** @@ -66,6 +69,35 @@ function directFixPayload(agentUuid: string) { }; } +async function seedAttributableTrial( + app: ReturnType>, + admin: Awaited>, + agentUuid: string, +): Promise<{ chatId: string; attemptId: string }> { + const { chatId } = await createMeChat(app.db, admin.humanAgentUuid, admin.organizationId, { + participantIds: [agentUuid], + topic: "Production scan trial", + }); + const attemptId = crypto.randomUUID(); + await app.db + .update(chats) + .set({ + metadata: buildLandingCampaignChatMetadata({ + campaign: "production-scan", + agentId: agentUuid, + skillSetId: "production-scan", + skillSetVersion: "test", + repo: { url: `https://github.com/${REPO_SLUG}`, canonicalKey: `github.com/${REPO_SLUG}` }, + attribution: { attemptId, variant: "control" }, + state: "completed", + inputLocked: true, + maxAgentTurns: 1, + }), + }) + .where(eq(chats.id, chatId)); + return { chatId, attemptId }; +} + describe("production-scan fix conversion — cross-path idempotency", () => { const getApp = useTestApp({ growthLandingPagesEnabled: true }); @@ -73,6 +105,7 @@ describe("production-scan fix conversion — cross-path idempotency", () => { const app = getApp(); const admin = await createTestAdmin(app); const agent = await createOrgAgent(app, admin); + const trial = await seedAttributableTrial(app, admin, agent.uuid); // PATH 1 — not-yet-onboarded: onboarding kickoff creates the fix launcher, // keyed `:scan-fix:`. @@ -107,12 +140,18 @@ describe("production-scan fix conversion — cross-path idempotency", () => { // appending a duplicate bootstrap message. const msgs = await app.db.select().from(messages).where(eq(messages.chatId, chat1)); expect(msgs).toHaveLength(1); + const [trialRow] = await app.db.select({ metadata: chats.metadata }).from(chats).where(eq(chats.id, trial.chatId)); + expect(parseLandingCampaignTrialChatMetadata(trialRow?.metadata)).toMatchObject({ + attribution: { attemptId: trial.attemptId, variant: "control" }, + actionConversion: { chatId: chat1, recordedAt: expect.any(String) }, + }); }); it("the direct path alone is idempotent on re-entry", async () => { const app = getApp(); const admin = await createTestAdmin(app); const agent = await createOrgAgent(app, admin); + const trial = await seedAttributableTrial(app, admin, agent.uuid); const url = `/api/v1/orgs/${encodeURIComponent(admin.organizationId)}/chats`; const headers = { authorization: `Bearer ${admin.accessToken}` }; @@ -121,6 +160,12 @@ describe("production-scan fix conversion — cross-path idempotency", () => { expect(a.json<{ chatId: string }>().chatId).toBe(b.json<{ chatId: string }>().chatId); const fixChats = await app.db.select().from(chats).where(eq(chats.topic, FIX_TOPIC)); expect(fixChats).toHaveLength(1); + const actionChatId = a.json<{ chatId: string }>().chatId; + const [trialRow] = await app.db.select({ metadata: chats.metadata }).from(chats).where(eq(chats.id, trial.chatId)); + expect(parseLandingCampaignTrialChatMetadata(trialRow?.metadata)?.actionConversion).toMatchObject({ + chatId: actionChatId, + recordedAt: expect.any(String), + }); }); it("the onboarding path alone is idempotent on re-entry", async () => { diff --git a/packages/server/src/api/internal/scan-campaign-exports.ts b/packages/server/src/api/internal/scan-campaign-exports.ts index 4487a6653..e7a6c2fe8 100644 --- a/packages/server/src/api/internal/scan-campaign-exports.ts +++ b/packages/server/src/api/internal/scan-campaign-exports.ts @@ -340,6 +340,11 @@ async function buildScanCampaignExportFromDb(db: Database, input: ScanCampaignEx maxEstimatedTokens: trial?.maxEstimatedTokens ?? null, limitReason: trial?.limitReason ?? null, repo: trial?.repo ?? null, + attemptId: trial?.attribution?.attemptId ?? null, + variant: trial?.attribution?.variant ?? null, + actionConverted: trial?.actionConversion !== undefined, + actionChatId: trial?.actionConversion?.chatId ?? null, + actionRecordedAt: trial?.actionConversion?.recordedAt ?? null, outcome: inferOutcome(trial?.state ?? null, counted.humanMessageCount), humanMessageCount: counted.humanMessageCount, agentMessageCount: counted.agentMessageCount, @@ -377,7 +382,7 @@ async function buildScanCampaignExportFromDb(db: Database, input: ScanCampaignEx const manifest = { exportId, createdAt, - schemaVersion: 1, + schemaVersion: 2, request: { clientId: input.clientId, agentName: input.agentName, diff --git a/packages/server/src/api/me.ts b/packages/server/src/api/me.ts index 3252e4866..3b015d090 100644 --- a/packages/server/src/api/me.ts +++ b/packages/server/src/api/me.ts @@ -43,6 +43,7 @@ import { campaignActionKickoffKey, hasTreeSetupKickoffMessage, kickoffOnboarding, + recordCampaignActionConversion, resolveCampaignActionContext, } from "../services/onboarding-kickoff.js"; import { getOrgContextTreeWithMeta } from "../services/org-settings.js"; @@ -372,6 +373,21 @@ export async function meRoutes(app: FastifyInstance): Promise { : `${humanAgentId}:${body.agentUuid}:onboarding`, complete: body.complete ?? true, }); + if (campaignAction) { + try { + await recordCampaignActionConversion(app.db, { + humanAgentId, + organizationId, + action: campaignAction, + actionChatId: result.chatId, + }); + } catch (err) { + app.log.warn( + { err, campaign: campaignAction.campaign, actionChatId: result.chatId }, + "landing campaign action conversion could not be recorded", + ); + } + } if (result.sent) { notifyRecipients(app.notifier, result.sent.recipients, result.sent.messageId); app.log.info({ event: "onboarding.kickoff", userId, chatId: result.chatId }, "onboarding funnel: kickoff"); diff --git a/packages/server/src/api/orgs/chats.ts b/packages/server/src/api/orgs/chats.ts index c4e4d7be9..49ba8013f 100644 --- a/packages/server/src/api/orgs/chats.ts +++ b/packages/server/src/api/orgs/chats.ts @@ -15,7 +15,11 @@ import { createChat, listChatsForMember, resolveAgentIdsByNameInOrg } from "../. import { assertNoLandingCampaignTrialAgents } from "../../services/landing-campaigns/guards.js"; import { createMeChat, listMeChatSourceCounts, listMeChats } from "../../services/me-chat.js"; import { notifyRecipients } from "../../services/notifier.js"; -import { campaignActionKickoffKey, resolveCampaignActionContext } from "../../services/onboarding-kickoff.js"; +import { + campaignActionKickoffKey, + recordCampaignActionConversion, + resolveCampaignActionContext, +} from "../../services/onboarding-kickoff.js"; /** * Class B — org-scoped chat collection routes. Mounted at @@ -149,6 +153,21 @@ export async function orgChatRoutes(app: FastifyInstance): Promise { ? { onboardingKickoffKey: campaignActionKickoffKey(scope.humanAgentId, campaignAction) } : {}), }); + if (campaignAction) { + try { + await recordCampaignActionConversion(app.db, { + humanAgentId: scope.humanAgentId, + organizationId: scope.organizationId, + action: campaignAction, + actionChatId: result.chat.id, + }); + } catch (err) { + app.log.warn( + { err, campaign: campaignAction.campaign, actionChatId: result.chat.id }, + "landing campaign action conversion could not be recorded", + ); + } + } notifyRecipients(app.notifier, result.recipients, result.message.id); return reply.status(201).send({ chatId: result.chat.id, diff --git a/packages/server/src/services/landing-campaigns/metadata.ts b/packages/server/src/services/landing-campaigns/metadata.ts index 55a7360d4..6ae2e91e9 100644 --- a/packages/server/src/services/landing-campaigns/metadata.ts +++ b/packages/server/src/services/landing-campaigns/metadata.ts @@ -1,4 +1,6 @@ import { + type LandingCampaignActionConversion, + type LandingCampaignAttribution, type LandingCampaignRepoMetadata, type LandingCampaignTrialAwaitingUserKind, type LandingCampaignTrialChatState, @@ -42,6 +44,8 @@ export function buildLandingCampaignChatMetadata(input: { skillSetId: string; skillSetVersion: string; repo: LandingCampaignRepoMetadata; + attribution?: LandingCampaignAttribution; + actionConversion?: LandingCampaignActionConversion; state: LandingCampaignTrialChatState; inputLocked: boolean; awaitingUserKind?: LandingCampaignTrialAwaitingUserKind; @@ -62,6 +66,8 @@ export function buildLandingCampaignChatMetadata(input: { skillSetId: input.skillSetId, skillSetVersion: input.skillSetVersion, repo: input.repo, + ...(input.attribution ? { attribution: input.attribution } : {}), + ...(input.actionConversion ? { actionConversion: input.actionConversion } : {}), state: input.state, inputLocked: input.inputLocked, ...(input.awaitingUserKind ? { awaitingUserKind: input.awaitingUserKind } : {}), diff --git a/packages/server/src/services/landing-campaigns/start.ts b/packages/server/src/services/landing-campaigns/start.ts index 7c397207f..b6f27e9a6 100644 --- a/packages/server/src/services/landing-campaigns/start.ts +++ b/packages/server/src/services/landing-campaigns/start.ts @@ -371,6 +371,7 @@ async function ensureTrialChatAndBootstrap( campaign: string; repo: LandingCampaignRepoMetadata; skillSet: LandingCampaignSkillSet; + attribution?: LandingCampaignStartRequest["attribution"]; }, ): Promise<{ chatId: string; sent?: { recipients: string[]; messageId: string } }> { const kickoffKey = [ @@ -387,6 +388,7 @@ async function ensureTrialChatAndBootstrap( skillSetId: input.skillSet.id, skillSetVersion: input.skillSet.version, repo: input.repo, + ...(input.attribution ? { attribution: input.attribution } : {}), state: "running", inputLocked: false, maxAgentTurns: app.config.growth.landingCampaignMaxAgentTurns, @@ -547,6 +549,7 @@ export async function startLandingCampaignTrial( campaign: body.campaign, repo, skillSet, + ...(body.attribution ? { attribution: body.attribution } : {}), }); if (result.sent) notifyRecipients(app.notifier, result.sent.recipients, result.sent.messageId); diff --git a/packages/server/src/services/onboarding-kickoff.ts b/packages/server/src/services/onboarding-kickoff.ts index aa8abd6a5..50626004c 100644 --- a/packages/server/src/services/onboarding-kickoff.ts +++ b/packages/server/src/services/onboarding-kickoff.ts @@ -1,5 +1,9 @@ -import type { LandingCampaignActionContext, SendMessage } from "@first-tree/shared"; -import { and, desc, eq, isNull, like, or } from "drizzle-orm"; +import { + type LandingCampaignActionContext, + parseLandingCampaignTrialChatMetadata, + type SendMessage, +} from "@first-tree/shared"; +import { and, desc, eq, isNull, like, or, sql } from "drizzle-orm"; import type { Database } from "../db/connection.js"; import { chatMembership } from "../db/schema/chat-membership.js"; import { chats } from "../db/schema/chats.js"; @@ -29,6 +33,72 @@ export function resolveCampaignActionContext( return legacyScanFixRepoSlug ? { campaign: "production-scan", repoSlug: legacyScanFixRepoSlug } : null; } +/** + * Attach a campaign action conversion to the caller's matching trial chat. + * + * The analytics export is intentionally limited to valid trial rows. Recording + * the conversion on that row keeps the export inside its existing trust + * boundary and avoids exporting ordinary task-chat content. A shared report + * opened by somebody who did not run the trial simply has no matching row and + * is left unattributed without blocking their task chat. + */ +export async function recordCampaignActionConversion( + db: Database, + input: { + humanAgentId: string; + organizationId: string; + action: LandingCampaignActionContext; + actionChatId: string; + }, +): Promise { + const canonicalRepoKey = `github.com/${input.action.repoSlug.toLowerCase()}`; + + return db.transaction(async (tx) => { + const [row] = await tx + .select({ id: chats.id, metadata: chats.metadata }) + .from(chats) + .innerJoin( + chatMembership, + and( + eq(chatMembership.chatId, chats.id), + eq(chatMembership.agentId, input.humanAgentId), + eq(chatMembership.accessMode, "speaker"), + ), + ) + .where( + and( + eq(chats.organizationId, input.organizationId), + sql`${chats.metadata}->'landingCampaignTrial'->>'campaign' = ${input.action.campaign}`, + sql`lower(${chats.metadata}->'landingCampaignTrial'->'repo'->>'canonicalKey') = ${canonicalRepoKey}`, + ), + ) + .orderBy(desc(chats.createdAt)) + .for("update") + .limit(1); + const trial = parseLandingCampaignTrialChatMetadata(row?.metadata); + if (!row || !trial) return false; + if (trial.actionConversion?.chatId === input.actionChatId) return true; + + await tx + .update(chats) + .set({ + metadata: { + ...row.metadata, + landingCampaignTrial: { + ...trial, + actionConversion: { + chatId: input.actionChatId, + recordedAt: new Date().toISOString(), + }, + }, + }, + updatedAt: new Date(), + }) + .where(eq(chats.id, row.id)); + return true; + }); +} + export type KickoffOnboardingArgs = { /** The membership whose completion is stamped once the chat exists. */ memberId: string; diff --git a/packages/shared/src/__tests__/landing-campaign-schema.test.ts b/packages/shared/src/__tests__/landing-campaign-schema.test.ts index 51c6ba6f9..8a0b204fd 100644 --- a/packages/shared/src/__tests__/landing-campaign-schema.test.ts +++ b/packages/shared/src/__tests__/landing-campaign-schema.test.ts @@ -54,6 +54,7 @@ describe("landing campaign metadata schemas", () => { }); it("parses valid trial chat metadata with defaults", () => { + const attemptId = "018f5f17-7bb0-7d6d-8d86-91c901d5f2bf"; expect( parseLandingCampaignTrialChatMetadata({ landingCampaignTrial: { @@ -62,6 +63,8 @@ describe("landing campaign metadata schemas", () => { skillSetId: "starter", skillSetVersion: "v1", repo, + attribution: { attemptId, variant: "control" }, + actionConversion: { chatId: "action-chat-1", recordedAt: "2026-07-16T12:00:00.000Z" }, state: "awaiting_user", inputLocked: true, awaitingUserKind: "request", @@ -73,6 +76,8 @@ describe("landing campaign metadata schemas", () => { skillSetId: "starter", skillSetVersion: "v1", repo, + attribution: { attemptId, variant: "control" }, + actionConversion: { chatId: "action-chat-1", recordedAt: "2026-07-16T12:00:00.000Z" }, state: "awaiting_user", inputLocked: true, awaitingUserKind: "request", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index feb22de6a..9e1636429 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -556,6 +556,8 @@ export { type KnownLandingCampaignSlug, knownLandingCampaignSlugSchema, type LandingCampaignActionContext, + type LandingCampaignActionConversion, + type LandingCampaignAttribution, type LandingCampaignRepoMetadata, type LandingCampaignStartRequest, type LandingCampaignStartResponse, @@ -565,6 +567,8 @@ export { type LandingCampaignTrialChatState, type LandingCampaignTrialLimitReason, landingCampaignActionContextSchema, + landingCampaignActionConversionSchema, + landingCampaignAttributionSchema, landingCampaignRepoMetadataSchema, landingCampaignRepoSlugSchema, landingCampaignSlugSchema, diff --git a/packages/shared/src/schemas/landing-campaign.ts b/packages/shared/src/schemas/landing-campaign.ts index fe8abd8a8..067d18e48 100644 --- a/packages/shared/src/schemas/landing-campaign.ts +++ b/packages/shared/src/schemas/landing-campaign.ts @@ -21,6 +21,27 @@ export const landingCampaignRepoSlugSchema = z .max(200) .regex(/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u); +export const landingCampaignAttributionSchema = z + .object({ + attemptId: z.string().uuid(), + variant: z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u), + }) + .strict(); +export type LandingCampaignAttribution = z.infer; + +export const landingCampaignActionConversionSchema = z + .object({ + chatId: z.string().min(1), + recordedAt: z.string().datetime(), + }) + .strict(); +export type LandingCampaignActionConversion = z.infer; + /** Trusted campaign action context carried by direct and onboarding chat creation. */ export const landingCampaignActionContextSchema = z .object({ @@ -34,6 +55,7 @@ export const landingCampaignStartRequestSchema = z.object({ organizationId: z.string().min(1).optional(), campaign: landingCampaignSlugSchema, repoUrl: repoUrlSchema, + attribution: landingCampaignAttributionSchema.optional(), }); export type LandingCampaignStartRequest = z.infer; @@ -76,6 +98,8 @@ export const landingCampaignTrialChatMetadataSchema = z.object({ skillSetId: z.string().min(1), skillSetVersion: z.string().min(1), repo: landingCampaignRepoMetadataSchema, + attribution: landingCampaignAttributionSchema.optional(), + actionConversion: landingCampaignActionConversionSchema.optional(), state: landingCampaignTrialChatStateSchema, inputLocked: z.boolean(), awaitingUserKind: landingCampaignTrialAwaitingUserKindSchema.optional(), diff --git a/packages/web/src/pages/quickstart/__tests__/intent.test.ts b/packages/web/src/pages/quickstart/__tests__/intent.test.ts index 689f6f63d..c0ff1fa4b 100644 --- a/packages/web/src/pages/quickstart/__tests__/intent.test.ts +++ b/packages/web/src/pages/quickstart/__tests__/intent.test.ts @@ -38,6 +38,7 @@ beforeEach(() => { }); const REPO_ENC = encodeURIComponent("https://github.com/acme/backend"); +const ATTEMPT_ID = "018f5f17-7bb0-7d6d-8d86-91c901d5f2bf"; const INTENT: CampaignIntent = { campaign: "production-scan", owner: "acme", @@ -80,6 +81,27 @@ describe("readCampaignHandoff", () => { expect(readCampaignHandoff({ search: `?campaign=production-scan&repo=${REPO_ENC}`, hash: "" })).toEqual(INTENT); }); + it("keeps valid anonymous attempt attribution and ignores incomplete or invalid values", () => { + expect( + readCampaignHandoff({ + search: `?campaign=production-scan&repo=${REPO_ENC}&attempt=${ATTEMPT_ID}&variant=control`, + hash: "", + }), + ).toEqual({ ...INTENT, attribution: { attemptId: ATTEMPT_ID, variant: "control" } }); + expect( + readCampaignHandoff({ + search: `?campaign=production-scan&repo=${REPO_ENC}&attempt=not-a-uuid&variant=control`, + hash: "", + }), + ).toEqual(INTENT); + expect( + readCampaignHandoff({ + search: `?campaign=production-scan&repo=${REPO_ENC}&attempt=${ATTEMPT_ID}`, + hash: "", + }), + ).toEqual(INTENT); + }); + it("supports the legacy intent= alias for production scan", () => { expect(readCampaignHandoff({ search: `?intent=production-scan&repo=${REPO_ENC}`, hash: "" })?.campaign).toBe( "production-scan", @@ -104,8 +126,9 @@ describe("readCampaignHandoff", () => { describe("campaign intent sessionStorage", () => { it("round-trips a valid intent in sessionStorage only (never localStorage)", () => { - writeCampaignIntent(INTENT); - expect(readCampaignIntent()).toEqual(INTENT); + const attributed = { ...INTENT, attribution: { attemptId: ATTEMPT_ID, variant: "control" } }; + writeCampaignIntent(attributed); + expect(readCampaignIntent()).toEqual(attributed); expect(window.localStorage?.getItem?.("first-tree:quickstart:intent") ?? null).toBeNull(); }); @@ -123,6 +146,14 @@ describe("campaign intent sessionStorage", () => { expect(readCampaignIntent()).toBeNull(); }); + it("rejects a stored intent with malformed attribution", () => { + window.sessionStorage.setItem( + "first-tree:quickstart:intent", + JSON.stringify({ ...INTENT, attribution: { attemptId: "bad", variant: "control" } }), + ); + expect(readCampaignIntent()).toBeNull(); + }); + it("can clear a valid intent once start chat begins", () => { writeCampaignIntent(INTENT); clearCampaignIntent(); diff --git a/packages/web/src/pages/quickstart/__tests__/quickstart-page.e2e.test.tsx b/packages/web/src/pages/quickstart/__tests__/quickstart-page.e2e.test.tsx index def849134..e57734736 100644 --- a/packages/web/src/pages/quickstart/__tests__/quickstart-page.e2e.test.tsx +++ b/packages/web/src/pages/quickstart/__tests__/quickstart-page.e2e.test.tsx @@ -143,19 +143,20 @@ async function flush(): Promise { }); } -function seedIntent(campaign: CampaignIntent["campaign"] = "production-scan"): void { +function seedIntent(campaign: CampaignIntent["campaign"] = "production-scan", attributed = false): void { writeCampaignIntent({ campaign, owner: "acme", repo: "backend", repoSlug: "acme/backend", url: "https://github.com/acme/backend", + ...(attributed ? { attribution: { attemptId: "018f5f17-7bb0-7d6d-8d86-91c901d5f2bf", variant: "control" } } : {}), }); } describe("QuickstartPage — landing campaign trial flow", () => { it("starts the landing campaign trial from stored intent, refreshes /me, clears intent, and navigates", async () => { - seedIntent("production-scan"); + seedIntent("production-scan", true); await renderPage(); expect(landingCampaignMock.startLandingCampaign).toHaveBeenCalledTimes(1); @@ -163,6 +164,7 @@ describe("QuickstartPage — landing campaign trial flow", () => { organizationId: "org-1", campaign: "production-scan", repoUrl: "https://github.com/acme/backend", + attribution: { attemptId: "018f5f17-7bb0-7d6d-8d86-91c901d5f2bf", variant: "control" }, }); expect(authMock.value.refreshMe).toHaveBeenCalled(); expect(readCampaignIntent()).toBeNull(); diff --git a/packages/web/src/pages/quickstart/intent.ts b/packages/web/src/pages/quickstart/intent.ts index 38891a347..10a694693 100644 --- a/packages/web/src/pages/quickstart/intent.ts +++ b/packages/web/src/pages/quickstart/intent.ts @@ -1,3 +1,4 @@ +import { type LandingCampaignAttribution, landingCampaignAttributionSchema } from "@first-tree/shared"; import { type CampaignSlug, getCampaign, isKnownCampaign } from "./campaigns.js"; /** @@ -20,7 +21,7 @@ export type RepoIntent = { }; /** A campaign handoff: a known campaign slug + the repo it targets. */ -export type CampaignIntent = RepoIntent & { campaign: CampaignSlug }; +export type CampaignIntent = RepoIntent & { campaign: CampaignSlug; attribution?: LandingCampaignAttribution }; /** A campaign result CTA landing back on Cloud for work by the user's agent. */ export type CampaignActionHandoff = CampaignIntent & { @@ -89,6 +90,14 @@ function normalizeAction(params: URLSearchParams): string | null { return action || null; } +function normalizeAttribution(params: URLSearchParams): LandingCampaignAttribution | undefined { + const parsed = landingCampaignAttributionSchema.safeParse({ + attemptId: params.get("attempt"), + variant: params.get("variant"), + }); + return parsed.success ? parsed.data : undefined; +} + /** Read a configured campaign action handoff (query first, then hash). */ export function readCampaignActionHandoff(location: Pick): CampaignActionHandoff | null { for (const params of [new URLSearchParams(location.search ?? ""), paramsFromHash(location.hash ?? "")]) { @@ -100,7 +109,16 @@ export function readCampaignActionHandoff(location: Pick) const repoRaw = params.get("repo"); if (!repoRaw) continue; const repo = normalizeGitHubRepoUrl(repoRaw); - if (repo) return { campaign, ...repo }; + if (repo) { + const attribution = normalizeAttribution(params); + return { campaign, ...repo, ...(attribution ? { attribution } : {}) }; + } } return null; } @@ -160,7 +181,18 @@ export function readCampaignIntent(): CampaignIntent | null { ) { throw new Error("invalid quickstart intent"); } - return { campaign: o.campaign, owner: o.owner, repo: o.repo, repoSlug: o.repoSlug, url: o.url }; + const parsedAttribution = + o.attribution === undefined ? null : landingCampaignAttributionSchema.safeParse(o.attribution); + if (parsedAttribution && !parsedAttribution.success) throw new Error("invalid quickstart attribution"); + const attribution = parsedAttribution?.data; + return { + campaign: o.campaign, + owner: o.owner, + repo: o.repo, + repoSlug: o.repoSlug, + url: o.url, + ...(attribution ? { attribution } : {}), + }; } catch { clearCampaignIntent(); return null; diff --git a/packages/web/src/pages/quickstart/quickstart-page.tsx b/packages/web/src/pages/quickstart/quickstart-page.tsx index 192fbcede..00dc9d1de 100644 --- a/packages/web/src/pages/quickstart/quickstart-page.tsx +++ b/packages/web/src/pages/quickstart/quickstart-page.tsx @@ -108,6 +108,7 @@ export function QuickstartPage() { ...(organizationId ? { organizationId } : {}), campaign: intent.campaign, repoUrl: intent.url, + ...(intent.attribution ? { attribution: intent.attribution } : {}), }); clearCampaignIntent(); await refreshMe();