Repository navigation
feat: bring a teammate into the first useful pod moment (TASK-515) #2708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
fe317a4
feat: offer teammate invites at the first useful pod moment
lilyshen0722 3b0f7e8
fix: follow numeral and screen reader style conventions for invites
lilyshen0722 c971c75
fix: preserve invite prompts in expanded threads and size starter acts
lilyshen0722 5d3c93b
fix: retain invite requests when thread reply slots move
lilyshen0722 6648c78
Require loaded history and share invite retry reads
lilyshen0722 43e88eb
Keep first reply invites off until initial history succeeds
lilyshen0722 2412d5c
Drop the private Scout first reply invite placement
lilyshen0722 ca1b7a4
copy: adjudicate Chinese teammate invite wording
lilyshen0722 3584d36
Merge main into TASK-515 keeping invite and trace layout checks
lilyshen0722 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
106 changes: 106 additions & 0 deletions
106
backend/__tests__/unit/routes/podInvites.funnel.test.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| const express = require('express'); | ||
| const request = require('supertest'); | ||
| const { setupMongoDb, closeMongoDb } = require('../../utils/testUtils'); | ||
| jest.mock('../../../middleware/auth', () => (req, res, next) => { | ||
| if (!req.get('x-user')) return res.sendStatus(401); | ||
| req.userId = req.get('x-user'); | ||
| req.user = { id: req.userId, role: req.get('x-role') || 'user' }; | ||
| return next(); | ||
| }); | ||
| jest.mock('../../../middleware/adminAuth', () => (req, res, next) => ( | ||
| req.user.role === 'admin' ? next() : res.sendStatus(403) | ||
| )); | ||
| jest.mock('../../../services/agentIdentityService', () => ({ DM_POD_TYPES_GUARD: new Set(['agent-room', 'agent-dm']) })); | ||
| jest.mock('../../../services/outsiderGrantAgentJoinPolicy', () => ({ assertInstalledAgentMayJoinPod: jest.fn() })); | ||
| const User = require('../../../models/User'); | ||
| const Pod = require('../../../models/Pod'); | ||
| const { PodInvite } = require('../../../models/PodInvite'); | ||
| const inviteRoutes = require('../../../routes/podInvites'); | ||
| const analyticsRoutes = require('../../../routes/admin/analytics'); | ||
| let app; | ||
| let alice; | ||
| let bob; | ||
| let staff; | ||
| let pod; | ||
| beforeAll(async () => { | ||
| await setupMongoDb(); | ||
| app = express(); app.use(express.json()); | ||
| app.use('/api', inviteRoutes); | ||
| app.use('/api/admin/analytics', analyticsRoutes); | ||
| }); | ||
| afterAll(closeMongoDb); | ||
| beforeEach(async () => { | ||
| await Promise.all([User.deleteMany({}), Pod.deleteMany({}), PodInvite.deleteMany({})]); | ||
| alice = await User.create({ username: 'alice-cohort', email: 'alice@outside-company.org', password: 'test-password-123', | ||
| onboarding: { podOpenedAt: new Date('2026-10-20T12:00:00Z') } }); | ||
| bob = await User.create({ username: 'bob-cohort', email: 'bob@outside-company.org', password: 'test-password-123', | ||
| onboarding: { podOpenedAt: new Date('2026-10-24T12:00:00Z') } }); | ||
| staff = await User.create({ username: 'staff-cohort', email: 'staff@outside-company.org', role: 'admin', password: 'test-password-123' }); | ||
| pod = await Pod.create({ name: 'Invite test', type: 'team', members: [alice._id], createdBy: alice._id }); | ||
| }); | ||
| const mint = (body = {}, user = alice) => request(app).post(`/api/pods/${pod.id}/invites`).set('x-user', user.id).send(body); | ||
| const redeem = (token, user) => request(app).post(`/api/invites/${token}/redeem`).set('x-user', user.id).send({}); | ||
| const stats = () => request(app).get('/api/admin/analytics/invites?days=30').set('x-user', staff.id).set('x-role', 'admin'); | ||
|
|
||
| test('mint binds the cohort to the authenticated inviter and normalizes the UI source', async () => { | ||
| const response = await mint({ source: 'first-agent-post', createdBy: bob.id, inviterFirstSessionAt: '1900-01-01' }); | ||
| expect(response.status).toBe(201); | ||
| const invite = await PodInvite.findOne({ token: response.body.token }).lean(); | ||
| expect(String(invite.createdBy)).toBe(alice.id); | ||
| expect(invite.source).toBe('first-agent-post'); | ||
| const links = await request(app).get(`/api/pods/${pod.id}/invites`).set('x-user', alice.id).expect(200); | ||
| expect(links.body).toEqual(expect.arrayContaining([expect.objectContaining({ | ||
| token: response.body.token, source: 'first-agent-post', createdBy: expect.objectContaining({ _id: alice.id }), | ||
| })])); | ||
| expect(invite.inviterFirstSessionAt.toISOString()).toBe('2026-10-20T12:00:00.000Z'); | ||
| const unknown = await mint({ source: 'not-a-placement' }); | ||
| expect((await PodInvite.findOne({ token: unknown.body.token })).source).toBe('pod-menu'); | ||
| expect((await mint({}, bob)).status).toBe(403); | ||
| }); | ||
| test('one outside teammate converts once; self uses, repeats and staff do not inflate it', async () => { | ||
| const response = await mint({ source: 'scout-first-session' }); | ||
| const token = response.body.token; | ||
| await redeem(token, alice).expect(200); | ||
| await redeem(token, staff).expect(200); | ||
| expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toBeUndefined(); | ||
| await redeem(token, bob).expect(200); | ||
| const first = (await PodInvite.findOne({ token })).firstTeammateJoinedAt; | ||
| expect(first).toBeInstanceOf(Date); | ||
| await redeem(token, bob).expect(200); | ||
| expect((await PodInvite.findOne({ token })).firstTeammateJoinedAt).toEqual(first); | ||
| const report = await stats().expect(200); | ||
| expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 1, inviters: 1, convertedInviters: 1 }]); | ||
| expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 1 }); | ||
| expect(JSON.stringify(report.body)).not.toContain(token); | ||
| expect(JSON.stringify(report.body)).not.toContain(alice.id); | ||
| }); | ||
| test('analytics excludes staff inviters and admits an explicit unobserved cohort', async () => { | ||
| await Pod.updateOne({ _id: pod._id }, { $addToSet: { members: staff._id } }); | ||
| await mint({}, staff).expect(201); | ||
| await User.updateOne({ _id: alice._id }, { $unset: { 'onboarding.podOpenedAt': '' } }); | ||
| await mint({ source: 'solo-pod' }).expect(201); | ||
| const report = await stats().expect(200); | ||
| expect(report.body.cohorts).toEqual([{ firstSessionDay: null, created: 1, used: 0, inviters: 1, convertedInviters: 0 }]); | ||
| expect(report.body.sources['pod-menu']).toBeUndefined(); | ||
| await request(app).get('/api/admin/analytics/invites').set('x-user', alice.id).expect(403); | ||
| await request(app).get('/api/admin/analytics/invites').expect(401); | ||
| }); | ||
|
|
||
| test('bot redemption does not count as a new outside teammate', async () => { | ||
| const bot = await User.create({ username: 'bot-cohort', email: 'bot@outside-company.org', password: 'test-password-123', isBot: true }); | ||
| const response = await mint({ source: 'scout-first-session' }); | ||
| await redeem(response.body.token, bot).expect(200); | ||
| expect((await PodInvite.findOne({ token: response.body.token })).firstTeammateJoinedAt).toBeUndefined(); | ||
| const report = await stats().expect(200); | ||
| expect(report.body.sources['scout-first-session']).toEqual({ created: 1, used: 0 }); | ||
| }); | ||
| test('invites outside the creation window contribute to neither cohorts nor sources', async () => { | ||
| await PodInvite.create({ token: 'f'.repeat(32), podId: pod.id, createdBy: alice.id, | ||
| source: 'pod-menu', createdAt: new Date(Date.now() - 31 * 86400000), | ||
| inviterFirstSessionAt: new Date('2026-09-01T12:00:00Z'), firstTeammateJoinedAt: new Date(), | ||
| }); | ||
| await mint({ source: 'solo-pod' }).expect(201); | ||
| const report = await stats().expect(200); | ||
| expect(report.body.sources).toEqual({ 'solo-pod': { created: 1, used: 0 } }); | ||
| expect(report.body.cohorts).toEqual([{ firstSessionDay: '2026-10-20', created: 1, used: 0, inviters: 1, convertedInviters: 0 }]); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import User from '../models/User'; | ||
| import { PodInvite, INVITE_SOURCES } from '../models/PodInvite'; | ||
| import { HUMAN_FILTER, internalAccountExclusion } from './userClassification'; | ||
|
|
||
| /** Aggregate counts only: token material and people never leave this service. */ | ||
| export async function readInviteFunnel(days: number) { | ||
| const since = new Date(Date.now() - days * 86400000); | ||
| const invites = await PodInvite.find({ createdAt: { $gte: since } }) | ||
| .select('createdBy source inviterFirstSessionAt firstTeammateJoinedAt').lean(); | ||
| const people = await User.find({ | ||
| _id: { $in: [...new Set(invites.map((invite) => String(invite.createdBy)))] }, | ||
| ...HUMAN_FILTER, ...internalAccountExclusion(), | ||
| }).select('_id').lean(); | ||
| const outside = new Set(people.map((person) => String(person._id))); | ||
| const cohorts = new Map<string, { firstSessionDay: string | null; created: number; used: number; | ||
| inviters: Set<string>; convertedInviters: Set<string> }>(); | ||
| const sources: Record<string, { created: number; used: number }> = {}; | ||
| for (const invite of invites) { | ||
| const inviterId = String(invite.createdBy); | ||
| if (!outside.has(inviterId)) continue; | ||
| const day = invite.inviterFirstSessionAt?.toISOString().slice(0, 10) || null; | ||
| const key = day || 'unobserved'; | ||
| const cohort = cohorts.get(key) || { firstSessionDay: day, created: 0, used: 0, | ||
| inviters: new Set<string>(), convertedInviters: new Set<string>() }; | ||
| cohort.created += 1; | ||
| cohort.inviters.add(inviterId); | ||
| if (invite.firstTeammateJoinedAt) { cohort.used += 1; cohort.convertedInviters.add(inviterId); } | ||
| cohorts.set(key, cohort); | ||
| const source = INVITE_SOURCES.find((candidate) => candidate === invite.source) || 'unattributed'; | ||
| sources[source] ||= { created: 0, used: 0 }; | ||
| sources[source].created += 1; | ||
| if (invite.firstTeammateJoinedAt) sources[source].used += 1; | ||
| } | ||
| return { | ||
| days, createdSince: since.toISOString(), cohortBasis: 'inviter-first-observed-pod-open', | ||
| // Legacy useCount counts even an existing member's repeated redemption. | ||
| // The new marker measures new outside-human joins, forward from deploy. | ||
| usageMeasuredFromDeploy: true, | ||
| cohorts: [...cohorts.values()].sort((a, b) => (a.firstSessionDay || '').localeCompare(b.firstSessionDay || '')) | ||
| .map(({ inviters, convertedInviters, ...cohort }) => ({ ...cohort, | ||
| inviters: inviters.size, convertedInviters: convertedInviters.size })), | ||
| sources, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| # The second-person invite moment | ||
|
|
||
| `V2BringTeammate` uses the existing pod invite model and member-authorized API. | ||
| It appears for the sole human in a group pod, after that person's first agent | ||
| reply, in the owned Scout workspace, or in the ordinary empty state. An | ||
| incomplete history cannot establish the first reply. Direct-message pod types | ||
| are excluded. Membership changes remove the prompt when a second human joins. | ||
|
|
||
| An impression performs only GET. A press reuses this user's usable link for the | ||
| same placement, or creates one with a seven-day expiry. Clipboard refusal keeps | ||
| and selects that link; retry never creates another token. Returning links are | ||
| shown without claiming the new browser has copied them. Dismissal is local to | ||
| the pod and placement. The just-created-pod starter also creates only on press. | ||
|
|
||
| The automatic first-reply card is suppressed in the viewer-owned My Workspace | ||
| to avoid prompting them to share a private Scout conversation. The group-pod | ||
| reply and one-person empty-state placements remain available. | ||
|
|
||
| Placement sources are `first-agent-post`, `scout-first-session`, and `solo-pod`; | ||
| `scout-first-session` is retained for historical attribution. Legacy/menu writes | ||
| default to `pod-menu`. Source is client-provided attribution, | ||
| not an authorization claim. Inviter identity and cohort time are server-bound. | ||
|
|
||
| ## Measurement | ||
|
|
||
| Admin-only `GET /api/admin/analytics/invites?days=30` returns aggregate counts, | ||
| with a one-to-90-day window. It excludes internal and bot inviters using the | ||
| shared classification policy and never returns invite tokens or person IDs. | ||
|
|
||
| - `created`: successful invite rows, counted by creation time and source. | ||
| - `used`: the first **new outside human** joining with that invite. Self uses, | ||
| already-member repeats, staff and bots do not convert a team. The existing | ||
| raw `useCount` still counts redemptions and is unchanged. | ||
| - `inviters` / `convertedInviters`: distinct outside inviters in each cohort. | ||
| - Cohort: UTC day of the inviter's immutable `onboarding.podOpenedAt`, copied at | ||
| mint. This is the first observed opening of a pod they created, not a claim | ||
| about an unobserved authentication session. Missing legacy marks are an | ||
| explicit `null` cohort. | ||
|
|
||
| Usage measurement starts at deployment: legacy redemption counters are not | ||
| backfilled as teammate joins. Telemetry failures do not undo a successful mint | ||
| or membership join. The endpoint reports the forward-only limitation explicitly. | ||
|
|
||
| The inline card's request state is owned above `V2ThreadMessages`; its view can | ||
| move between collapsed, visible and hidden-prefix reply slots without cancelling | ||
| a pending press. The owner renders no wrapper DOM, and remains inert until the | ||
| first-reply moment qualifies. Pod, viewer and placement still key its lifetime. | ||
| Settled reply views match the original source message ID, not the ruling's ID. |
4 changes: 4 additions & 0 deletions
4
frontend/design-system/preview/components-bring-teammate.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Bring a teammate · Signal</title><link rel="stylesheet" href="../../src/v2/v2.css"><link rel="stylesheet" href="../../src/v2/components/V2BringTeammate.css"><style>body{margin:0}.v2-root{display:block;height:auto;padding:24px;min-height:100vh}h1{font-family:var(--v2-font-display);font-size:32px;letter-spacing:-.03em}p{max-width:640px}</style></head><body><main class="v2-root"><h1>Bring a teammate</h1><p>One card in three placements. Tokens and exact copy follow TASK-515. The interactive proof uses the production component; this page is the static component specimen.</p> | ||
| <section class="v2-teammate v2-teammate--message"><div class="v2-teammate__kicker">only you here</div><h3>Invite a teammate to Launch planning</h3><p class="v2-teammate__body">They can work with Scout too. They'll see everything in this pod, including earlier messages.</p><div class="v2-teammate__acts"><button class="v2-teammate__copy">Copy invite link</button><button class="v2-teammate__dismiss" aria-label="Dismiss">×</button></div></section> | ||
| <section class="v2-teammate v2-teammate--message"><div class="v2-teammate__kicker">only you here</div><h3>Invite a teammate to My Workspace</h3><p class="v2-teammate__body">Scout can split this work between you. They'll see this pod, including this conversation with Scout.</p><div class="v2-teammate__acts"><button class="v2-teammate__copy v2-teammate__copy--quiet">Copied</button><button class="v2-teammate__dismiss" aria-label="Dismiss">×</button></div><div class="v2-teammate__link"><input aria-label="Invite link" readonly value="https://example.invalid/v2/invite/sample"><p>works for 7 days · anyone signed in with this link can join</p></div></section> | ||
| <section class="v2-teammate v2-teammate--empty"><div class="v2-teammate__kicker">only you here</div><h3>Invite a teammate to Launch planning</h3><p class="v2-teammate__body">They can post here and work with the agents in this pod. They'll see everything posted here.</p><div class="v2-teammate__acts"><button class="v2-teammate__copy">Try again</button><button class="v2-teammate__dismiss" aria-label="Dismiss">×</button></div><p class="v2-teammate__error">couldn't create a link · nothing was shared</p></section></main></body></html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.