Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions backend/__tests__/unit/routes/podInvites.funnel.test.js
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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 }]);
});
10 changes: 10 additions & 0 deletions backend/models/PodInvite.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import mongoose, { Schema, Document, Types, Model } from 'mongoose';

export const INVITE_SOURCES = ['pod-menu', 'first-agent-post', 'scout-first-session', 'solo-pod'] as const;

export interface IPodInvite extends Document {
token: string;
podId: Types.ObjectId;
Expand All @@ -9,6 +11,11 @@ export interface IPodInvite extends Document {
useCount: number;
revokedAt?: Date | null;
lastUsedAt?: Date | null;
source?: typeof INVITE_SOURCES[number];
/** Snapshot of the inviter's first observed workspace session, never client-set. */
inviterFirstSessionAt?: Date;
/** First distinct outside human joining; repeated uses and bots never set it. */
firstTeammateJoinedAt?: Date;
createdAt: Date;
updatedAt: Date;
isUsable(): boolean;
Expand All @@ -24,6 +31,9 @@ const PodInviteSchema = new Schema<IPodInvite>(
useCount: { type: Number, default: 0 },
revokedAt: { type: Date, default: null },
lastUsedAt: { type: Date, default: null },
source: { type: String, enum: INVITE_SOURCES },
inviterFirstSessionAt: { type: Date },
firstTeammateJoinedAt: { type: Date },
},
{ timestamps: true },
);
Expand Down
12 changes: 12 additions & 0 deletions backend/routes/admin/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ const pgUserIdsWithMessages = async (userIds: string[]): Promise<Set<string>> =>
return new Set(result.rows.map((r: { user_id: string }) => String(r.user_id)));
};

// Invite creation -> first distinct outside teammate, by inviter session cohort.
router.get('/invites', adminReadLimiter, auth, adminAuth, async (req: any, res: any) => {
try {
const days = Math.max(1, Math.min(90, parseInt(req.query.days, 10) || 30));
const { readInviteFunnel } = require('../../services/inviteFunnelService');
return res.json(await readInviteFunnel(days));
} catch (error) {
console.error('[invite-funnel] read failed:', (error as Error).message);
return res.status(503).json({ error: 'invite_funnel_unavailable' });
}
});

// GET /api/admin/analytics/funnel?days=30
// Daily signup cohorts with: signups, attached ≥1 agent, sent ≥1 message,
// returned D1/D7 (proxy), plus a totals row with rates.
Expand Down
32 changes: 31 additions & 1 deletion backend/routes/podInvites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { assertInstalledAgentMayJoinPod } from '../services/outsiderGrantAgentJo
const router = express.Router();
const auth = require('../middleware/auth');
const Pod = require('../models/Pod');
const { PodInvite } = require('../models/PodInvite');
const { PodInvite, INVITE_SOURCES } = require('../models/PodInvite');
const { HUMAN_FILTER, internalAccountExclusion } = require('../services/userClassification');

const getUserId = (req: any) => req.userId || req.user?.id || req.user?._id;

Expand Down Expand Up @@ -65,6 +66,16 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any,
return res.status(403).json({ msg: 'Only pod members can create invites' });
}
const { expiresInHours, maxUses } = req.body || {};
// Attribution is optional telemetry. Old clients still mint the same link.
// Source is a closed UI label; the cohort is always read from User.
const source = INVITE_SOURCES.find((candidate: string) => candidate === req.body?.source) || 'pod-menu';
let inviterFirstSessionAt: Date | undefined;
try {
const inviter = await User.findById(userId).select('onboarding.podOpenedAt').lean();
inviterFirstSessionAt = inviter?.onboarding?.podOpenedAt || undefined;
} catch (error) {
console.warn('[invite-funnel] cohort read failed:', (error as Error).message);
}
const token = crypto.randomBytes(16).toString('hex');
const expiresAt = expiresInHours && Number(expiresInHours) > 0
? new Date(Date.now() + Number(expiresInHours) * 3600 * 1000)
Expand All @@ -73,6 +84,8 @@ router.post('/pods/:podId/invites', inviteWriteRateLimit, auth, async (req: any,
token,
podId: pod._id,
createdBy: userId,
source,
...(inviterFirstSessionAt ? { inviterFirstSessionAt } : {}),
expiresAt,
maxUses: maxUses && Number(maxUses) > 0 ? Number(maxUses) : null,
});
Expand Down Expand Up @@ -117,6 +130,7 @@ router.get('/pods/:podId/invites', inviteReadRateLimit, auth, async (req: any, r
expiresAt: invite.expiresAt,
maxUses: invite.maxUses,
uses: Number(invite.useCount) || 0,
source: invite.source || null,
})));
} catch (err: any) {
console.error('List invites failed:', err.message);
Expand Down Expand Up @@ -268,6 +282,22 @@ router.post('/invites/:token/redeem', inviteWriteRateLimit, auth, async (req: an
pod.members.push(userId);
pod.updatedAt = new Date();
await pod.save();
// A token use by its author, an existing member, a bot or an internal
// account is not the outside team's second-person conversion.
if (String(invite.createdBy) !== String(userId)) {
try {
const outsidePerson = await User.exists({
_id: userId, ...HUMAN_FILTER, ...internalAccountExclusion(),
});
if (outsidePerson) await PodInvite.updateOne({
_id: invite._id, firstTeammateJoinedAt: { $exists: false },
}, { $set: { firstTeammateJoinedAt: new Date() } });
} catch (error) {
// Membership has already been committed. Telemetry cannot undo a
// successful join or turn it into a misleading client error.
console.warn('[invite-funnel] conversion write failed:', (error as Error).message);
}
}
}
invite.useCount += 1;
invite.lastUsedAt = new Date();
Expand Down
44 changes: 44 additions & 0 deletions backend/services/inviteFunnelService.ts
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,
};
}
48 changes: 48 additions & 0 deletions docs/development/invite-funnel.md
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 frontend/design-system/preview/components-bring-teammate.html
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>
1 change: 1 addition & 0 deletions frontend/design-system/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
--c-info: #0891b2; /* cyan — distinct from accent blue */
--c-info-soft: #dff4f8;
--c-danger: #ef4444;
--c-danger-text: #b91c1c;
--c-danger-soft: rgba(239, 68, 68, 0.10);

/* ---------- Agent role accents (avatar tints, role chips) ---------- */
Expand Down
Loading
Loading