From 5bd15acd77b85195093cf91df69413d3d8ee0cfb Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:45:14 -0700 Subject: [PATCH 1/2] feat(onboarding): write-once marks for the invite card and the BYO offer (D139) Three fixed byo-step names set User.onboarding marks through one allowlisted markSurface: inviteCardShownAt, inviteCardDismissedAt and byoOfferShownAt. The browser sends each "shown" ping once per page load; the server write is write-once in the filter and never for bots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- .../unit/routes/stats.byo-step.test.js | 20 ++++++++++ .../unit/services/onboardingMarks.test.js | 40 ++++++++++++++++++- backend/models/User.ts | 8 ++++ backend/routes/stats.ts | 10 ++++- backend/services/onboardingMarks.ts | 33 +++++++++++++++ .../src/v2/__tests__/V2BringTeammate.test.tsx | 25 ++++++++++++ .../src/v2/__tests__/V2ThreadStarts.test.tsx | 16 ++++++++ .../src/v2/__tests__/byoTelemetry.test.ts | 26 ++++++++++++ .../src/v2/components/V2BringTeammate.tsx | 6 ++- frontend/src/v2/components/V2Thread.tsx | 7 ++++ frontend/src/v2/utils/byoTelemetry.ts | 14 ++++++- 11 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 frontend/src/v2/__tests__/byoTelemetry.test.ts diff --git a/backend/__tests__/unit/routes/stats.byo-step.test.js b/backend/__tests__/unit/routes/stats.byo-step.test.js index 8f6fd13fb9..a0952ebd97 100644 --- a/backend/__tests__/unit/routes/stats.byo-step.test.js +++ b/backend/__tests__/unit/routes/stats.byo-step.test.js @@ -2,6 +2,7 @@ const request = require('supertest'); const express = require('express'); const mockMarkByoCommandCopied = jest.fn(); +const mockMarkSurface = jest.fn(); const mockInsertOne = jest.fn().mockResolvedValue({ acknowledged: true }); const mockPassThrough = () => (_req, _res, next) => next(); @@ -18,6 +19,7 @@ jest.mock('../../../middleware/auth', () => (req, _res, next) => { }); jest.mock('../../../services/onboardingMarks', () => ({ markByoCommandCopied: (...args) => mockMarkByoCommandCopied(...args), + markSurface: (...args) => mockMarkSurface(...args), })); jest.mock('express-rate-limit', () => ({ __esModule: true, @@ -66,6 +68,23 @@ describe('POST /api/stats/byo-step', () => { expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) }); }); + it.each([ + ['invite-card-shown', 'inviteCardShownAt'], + ['invite-card-dismissed', 'inviteCardDismissedAt'], + ['byo-offer-shown', 'byoOfferShownAt'], + ])('%s sets the %s mark and logs only the fixed step', async (step, mark) => { + const res = await request(app).post('/api/stats/byo-step').send({ step, podId: 'p1' }); + expect(res.status).toBe(200); + expect(mockMarkSurface).toHaveBeenCalledWith('human-1', mark); + expect(mockMarkByoCommandCopied).not.toHaveBeenCalled(); + expect(mockInsertOne).toHaveBeenCalledWith({ userId: 'human-1', step, at: expect.any(Date) }); + }); + + it('other steps set no surface mark', async () => { + await request(app).post('/api/stats/byo-step').send({ step: 'page-viewed' }); + expect(mockMarkSurface).not.toHaveBeenCalled(); + }); + it('rejects unknown steps without writing a mark or telemetry event', async () => { const res = await request(app) .post('/api/stats/byo-step') @@ -73,6 +92,7 @@ describe('POST /api/stats/byo-step', () => { expect(res.status).toBe(400); expect(mockMarkByoCommandCopied).not.toHaveBeenCalled(); + expect(mockMarkSurface).not.toHaveBeenCalled(); expect(mockInsertOne).not.toHaveBeenCalled(); }); }); diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 1cc7f57bd9..375911f4cb 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -10,7 +10,7 @@ jest.mock('../../../models/User', () => ({ const { markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied, - markByoConnectionAttempt, markWelcomeHostedStart, resetOnboardingMarksForTest, + markByoConnectionAttempt, markWelcomeHostedStart, markSurface, resetOnboardingMarksForTest, } = require('../../../services/onboardingMarks'); const flush = () => new Promise((r) => setImmediate(r)); @@ -203,6 +203,44 @@ describe('BYO setup marks', () => { }); }); +describe('markSurface (D139)', () => { + test.each(['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'])('%s is write-once in the filter, never for bots', (mark) => { + markSurface('u1', mark); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + const [filter, update] = mockUpdateOne.mock.calls[0]; + expect(filter).toEqual({ _id: 'u1', isBot: { $ne: true }, [`onboarding.${mark}`]: { $exists: false } }); + expect(Object.keys(update.$set)).toEqual([`onboarding.${mark}`]); + expect(update.$set[`onboarding.${mark}`]).toBeInstanceOf(Date); + }); + + test('refuses any field outside the three surface marks, and no person means no write', () => { + markSurface('u1', 'podOpenedAt'); + markSurface('u1', 'isAdmin'); + markSurface('', 'inviteCardShownAt'); + markSurface(undefined, 'byoOfferShownAt'); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + test('one write per person per mark per process; each mark is independent', () => { + markSurface('u1', 'inviteCardShownAt'); + markSurface('u1', 'inviteCardShownAt'); + markSurface('u1', 'inviteCardDismissedAt'); + markSurface('u2', 'inviteCardShownAt'); + expect(mockUpdateOne).toHaveBeenCalledTimes(3); + }); + + test('a failed write is retried on the next ping and never throws; reset forgets', async () => { + mockUpdateOne.mockRejectedValueOnce(new Error('mongo down')); + expect(() => markSurface('u1', 'byoOfferShownAt')).not.toThrow(); + await flush(); + markSurface('u1', 'byoOfferShownAt'); + expect(mockUpdateOne).toHaveBeenCalledTimes(2); + resetOnboardingMarksForTest(); + markSurface('u1', 'byoOfferShownAt'); + expect(mockUpdateOne).toHaveBeenCalledTimes(3); + }); +}); + describe('the onboarding marks together', () => { const fieldOf = ([filter]) => Object.keys(filter)[2]; diff --git a/backend/models/User.ts b/backend/models/User.ts index 3be3e3ba82..5ba548a11e 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -216,6 +216,11 @@ export interface IUser extends Document { byoCommandType?: 'mcp' | 'cli'; /** First authenticated BYO runtime request, never overwritten. */ byoFirstConnectionAttemptAt?: Date; + /** First "invite a teammate" card mounted, and first dismissed (D139). */ + inviteCardShownAt?: Date; + inviteCardDismissedAt?: Date; + /** First Bring your own start mounted in a thread (D139). */ + byoOfferShownAt?: Date; // The first-touch ref code the signup arrived with (?ref=x), set once at // account creation (utils/signupRef.ts). signupRef?: string; @@ -463,6 +468,9 @@ const userSchema = new Schema({ byoCommandCopiedAt: { type: Date }, byoCommandType: { type: String, enum: ['mcp', 'cli'] }, byoFirstConnectionAttemptAt: { type: Date }, + inviteCardShownAt: { type: Date }, + inviteCardDismissedAt: { type: Date }, + byoOfferShownAt: { type: Date }, signupRef: { type: String }, }, lastDigestSent: { type: Date }, diff --git a/backend/routes/stats.ts b/backend/routes/stats.ts index cc6993985e..d9b03cdaf3 100644 --- a/backend/routes/stats.ts +++ b/backend/routes/stats.ts @@ -78,7 +78,7 @@ router.get('/public', statsPublicLimit, async (_req: unknown, res: { json: (d: u // eslint-disable-next-line global-require const auth = require('../middleware/auth'); // eslint-disable-next-line global-require -const { markByoCommandCopied } = require('../services/onboardingMarks'); +const { markByoCommandCopied, markSurface } = require('../services/onboardingMarks'); // eslint-disable-next-line global-require const { rateLimit } = require('express-rate-limit'); // eslint-disable-next-line global-require @@ -89,7 +89,14 @@ const mongoose = require('mongoose'); const BYO_STEPS = new Set([ 'page-viewed', 'seat-created', 'mcp-command-copied', 'cli-command-copied', 'token-copied', 'listen-confirmed', 'listen-timeout', 'hosted-offer-shown', 'hosted-offer-clicked', + 'invite-card-shown', 'invite-card-dismissed', 'byo-offer-shown', ]); +// D139 surfaces: each step also sets its write-once onboarding mark. +const SURFACE_STEPS: Record = { + 'invite-card-shown': 'inviteCardShownAt', + 'invite-card-dismissed': 'inviteCardDismissedAt', + 'byo-offer-shown': 'byoOfferShownAt', +}; router.post('/byo-step', rateLimit({ windowMs: 60_000, @@ -107,6 +114,7 @@ router.post('/byo-step', rateLimit({ // the copied command, which contains a runtime token. markByoCommandCopied(req.userId, step === 'mcp-command-copied' ? 'mcp' : 'cli'); } + if (SURFACE_STEPS[step]) markSurface(req.userId, SURFACE_STEPS[step]); try { await mongoose.connection.db.collection('byo_telemetry').insertOne({ userId: req.userId, diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 81b0f739a4..a9d501c1ef 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -25,6 +25,12 @@ * read from the page's own load request (GET /api/hosted/availability, which * nothing else calls). It tells "never looked at setup" apart from "looked and * left". + * + * Three surface marks come from the browser through POST /api/stats/byo-step, + * as fixed step names (D139): inviteCardShownAt and inviteCardDismissedAt for + * the "invite a teammate" card, and byoOfferShownAt the first time the thread + * shows a start that links to Bring your own. "Shown" is the card or start + * mounting in the transcript, not proof it was scrolled into view. */ import User from '../models/User'; import { SCOUT_STARTS } from '../config/native-agents/scout'; @@ -34,6 +40,10 @@ const startPickedMarked = new Set(); const byoOpenedMarked = new Set(); const byoCommandCopiedMarked = new Set(); const byoConnectionAttemptMarked = new Set(); +const surfaceMarked = new Map>(); + +export type SurfaceMark = 'inviteCardShownAt' | 'inviteCardDismissedAt' | 'byoOfferShownAt'; +export const SURFACE_MARKS: readonly SurfaceMark[] = ['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt']; export function markPodOpened(userId: unknown, pod: { createdBy?: unknown } | null | undefined): void { const id = String(userId || ''); @@ -180,6 +190,28 @@ export function markByoConnectionAttempt( } } +/** Record the first time a person met one of the D139 surfaces. */ +export function markSurface(userId: unknown, mark: SurfaceMark): void { + const id = String(userId || ''); + if (!id || !SURFACE_MARKS.includes(mark)) return; + const marked = surfaceMarked.get(mark) || new Set(); + surfaceMarked.set(mark, marked); + if (marked.has(id)) return; + marked.add(id); + const field = `onboarding.${mark}`; + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, [field]: { $exists: false } }, + { $set: { [field]: new Date() } }, + ).catch(() => { + marked.delete(id); + }); + } catch { + // Telemetry fails open: it must never break the ping that carries it. + marked.delete(id); + } +} + /** Test seam: forget which people this process has marked. */ export function resetOnboardingMarksForTest(): void { podOpenedMarked.clear(); @@ -187,4 +219,5 @@ export function resetOnboardingMarksForTest(): void { byoOpenedMarked.clear(); byoCommandCopiedMarked.clear(); byoConnectionAttemptMarked.clear(); + surfaceMarked.clear(); } diff --git a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx index 8a188ae5a3..7dd5061568 100644 --- a/frontend/src/v2/__tests__/V2BringTeammate.test.tsx +++ b/frontend/src/v2/__tests__/V2BringTeammate.test.tsx @@ -8,6 +8,12 @@ const mockWrite = jest.fn(); const mockApi = { get: mockGet, post: mockPost }; jest.mock('../hooks/useV2Api', () => ({ useV2Api: () => mockApi })); jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const mockRecord = jest.fn(); +const mockRecordOnce = jest.fn(); +jest.mock('../utils/byoTelemetry', () => ({ + recordByoStep: (...args: unknown[]) => mockRecord(...args), + recordByoStepOnce: (...args: unknown[]) => mockRecordOnce(...args), +})); const token = 'a'.repeat(32); const props = { podId: 'pod', podName: 'Work', userId: 'person', source: 'solo-pod' as const }; const press = () => fireEvent.click(screen.getByRole('button', { name: 'teammate.act' })); @@ -120,3 +126,22 @@ test('a retry shares its read across visibility changes and retains the new link expect(mockWrite).toHaveBeenCalledTimes(1); expect(screen.getByRole('textbox')).toHaveValue(`${window.location.origin}/v2/invite/${token}`); }); + +test('D139 marks: showing the card pings invite-card-shown; dismissing pings invite-card-dismissed', () => { + const view = render(); + expect(mockRecordOnce).not.toHaveBeenCalled(); + view.rerender(); + expect(mockRecordOnce).toHaveBeenCalledWith(mockApi, 'invite-card-shown'); + expect(mockRecord).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' })); + expect(mockRecord).toHaveBeenCalledWith(mockApi, 'invite-card-dismissed'); +}); + +test('D139 marks: a card dismissed earlier never pings shown', () => { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'teammate.dismiss' })); + view.unmount(); + mockRecordOnce.mockClear(); + render(); + expect(mockRecordOnce).not.toHaveBeenCalled(); +}); diff --git a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx index e1baa27546..e0d88c9d5d 100644 --- a/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx +++ b/frontend/src/v2/__tests__/V2ThreadStarts.test.tsx @@ -7,10 +7,13 @@ // can take the offer away before it is seen. import React, { useState } from 'react'; import { + cleanup, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import { MemoryRouter, useLocation } from 'react-router-dom'; +import axios from 'axios'; import V2Thread from '../components/V2Thread'; +import { resetByoStepsForTest } from '../utils/byoTelemetry'; import { AuthContext } from '../../context/AuthContext'; jest.mock('../../context/SocketContext', () => ({ @@ -242,6 +245,19 @@ describe('V2Thread — the connect start links Bring your own', () => { expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); }); + test('the link pings byo-offer-shown once; a thread without it does not', async () => { + resetByoStepsForTest(); + const pings = () => axios.post.mock.calls.filter(([url, body]) => String(url).endsWith('/api/stats/byo-step') + && body?.step === 'byo-offer-shown').length; + axios.post.mockClear(); + renderThread([welcome]); + expect(pings()).toBe(0); + cleanup(); + renderThread([welcome, message('mine', 'u1', 'maya', CONNECT, 4)]); + await screen.findByRole('button', { name: OPEN_BYO }); + expect(pings()).toBe(1); + }); + test('a message from Scout with that text gets no link', () => { renderThread([message('echo', 'scout-1', 'scout', CONNECT, 4, { user: { username: 'scout', isBot: true } })]); expect(screen.queryByRole('button', { name: OPEN_BYO })).not.toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/byoTelemetry.test.ts b/frontend/src/v2/__tests__/byoTelemetry.test.ts new file mode 100644 index 0000000000..b5fae8035b --- /dev/null +++ b/frontend/src/v2/__tests__/byoTelemetry.test.ts @@ -0,0 +1,26 @@ +import { recordByoStep, recordByoStepOnce, resetByoStepsForTest } from '../utils/byoTelemetry'; + +const post = jest.fn(); +const api = { post } as unknown as Parameters[0]; + +beforeEach(() => { + post.mockReset(); + post.mockResolvedValue({}); + resetByoStepsForTest(); +}); + +test('a surface step is sent once per page load, each step on its own', () => { + recordByoStepOnce(api, 'invite-card-shown'); + recordByoStepOnce(api, 'invite-card-shown'); + recordByoStepOnce(api, 'byo-offer-shown'); + expect(post.mock.calls).toEqual([ + ['/api/stats/byo-step', { step: 'invite-card-shown' }], + ['/api/stats/byo-step', { step: 'byo-offer-shown' }], + ]); +}); + +test('a failed ping never throws into the caller', async () => { + post.mockRejectedValue(new Error('offline')); + expect(() => recordByoStep(api, 'invite-card-dismissed')).not.toThrow(); + await Promise.resolve(); +}); diff --git a/frontend/src/v2/components/V2BringTeammate.tsx b/frontend/src/v2/components/V2BringTeammate.tsx index e43d14c608..a24c489623 100644 --- a/frontend/src/v2/components/V2BringTeammate.tsx +++ b/frontend/src/v2/components/V2BringTeammate.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; +import { recordByoStep, recordByoStepOnce } from '../utils/byoTelemetry'; import './V2BringTeammate.css'; export type TeammateInviteSource = 'first-agent-post' | 'scout-first-session' | 'solo-pod'; @@ -137,7 +138,9 @@ const BringTeammateContent: React.FC = ({ const onShownRef = useRef(onShown); onShownRef.current = onShown; useEffect(() => { - if (shown) onShownRef.current?.(); + if (!shown) return; + onShownRef.current?.(); + recordByoStepOnce(apiRef.current, 'invite-card-shown'); }, [shown]); const card = shown ? (
@@ -152,6 +155,7 @@ const BringTeammateContent: React.FC = ({