Skip to content

Commit 1aacb58

Browse files
feat(invitations): referral substrate — referredBy + invitation.accepted event (P8a) (#3824)
* feat(invitations): referral substrate — referredBy + invitation.accepted (#3814) P8a of the invitations↔org decouple epic: wire the referral substrate (schema + event only, NO credit-grant logic). - E14: add server-only `referredBy` (ObjectId ref User, default:null) to the User Mongoose model. Deliberately NOT in the Zod schema — the signup POST path replaces req.body with the full Zod output, so any field in the schema would become client-writable; omitting it makes Zod strip it from every client parse (signup + PUT). Documented inline. - E22: new InvitationsService.accept(invite, userId) — the shared accept seam invoked by the eligibility `finalize` closure on BOTH the token two-phase path AND the OAuth-by-email path. Finalizes the invite, stamps referredBy = invite.invitedBy server-side via UserService.updateById (raw, bypasses the client whitelist), and emits `invitation.accepted`. Best-effort side-effects (a write failure or listener throw never breaks signup). invitedBy may be null (admin-created invite): skip the redundant null write, still emit with invitedBy:null. auth.controller stays import-free (relays the closure unchanged; the name stays `finalize`). - billing.init: no-op `invitation.accepted` listener (+ TODO(#5)) proving the fire-and-forget seam end-to-end (billing → invitations consumer). - events.js: finalize the documented payload { invitationId, email, invitedBy, acceptedUserId }. E20: verified `referredBy` is absent from config.whitelists.users.update + updateAdmin (removeSensitive strips it); added negative tests on PUT /api/users and the admin update path. End-to-end tests: token + OAuth invited signups set referredBy from the inviter (a client-supplied referredBy in the body is ignored); the event fires with the documented payload. Tests: full unit suite green (1899); invitations + users integration green. * docs(invitations): scope sync-throw guard claim + reconciliation log + read-exposure note Review follow-ups (Approved-with-minors, no behavior change): - events.js: clarify the emit-site try/catch only catches SYNCHRONOUS listener throws; an async listener rejection escapes as unhandledRejection. Warn the future #5 credit-grant listener to self-guard (or switch emit to Promise.allSettled). Mirror as a TODO(#5) at the billing no-op listener site. - invitations.service.js: add invitationId to the best-effort referredBy-write warn so a lost-attribution event is self-contained for tracing. - users.schema.js: note that reading/exposing referredBy (e.g. P8b view) must go via a response projection / read whitelist, never by adding it to this Zod schema (also the signup-POST write surface — would reopen the client-writable hole). * fix(invitations): guard accept() side-effects behind finalize result + add JSDoc - Accept() now returns null immediately when finalize() returns null (duplicate-accept / revoked / missing invite), preventing spurious referredBy writes and invitation.accepted emits for non-finalized invites. - Add JSDoc to billing.init invitation.accepted listener callback. - Extract finalize closure in invitations.init into named finalizeInvite() with JSDoc header. - Add unit test: finalize() null → no side-effects, return null.
1 parent 609771f commit 1aacb58

12 files changed

Lines changed: 449 additions & 9 deletions

modules/billing/billing.init.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import config from '../../config/index.js';
66
import AnalyticsService from '../../lib/services/analytics.js';
77
import logger from '../../lib/services/logger.js';
88
import billingEvents from './lib/events.js';
9+
import invitationEvents from '../invitations/lib/events.js';
910
import BillingUsageRepository from './repositories/billing.usage.repository.js';
1011
import { getAlertThresholdPercents } from './lib/billing.constants.js';
1112
import { setupBillingEmails } from './billing.email.js';
@@ -44,6 +45,26 @@ export default async (app) => {
4445
// Wire billing email listeners (quota warnings + payment-failed notifications).
4546
setupBillingEmails();
4647

48+
// Referral substrate (#5) — billing is an OPTIONAL consumer of the invitations
49+
// fire-and-forget `invitation.accepted` event (dependency direction billing →
50+
// invitations is fine: billing imports the events singleton, invitations never
51+
// imports billing). This listener is a deliberate NO-OP that only PROVES the event
52+
// seam works end-to-end (the payload arrives here on every invite acceptance); the
53+
// actual credit-grant logic lands in #5. Wrapped so a future grant impl can't crash
54+
// boot/signup. Mirrors the other cross-module listeners wired on this init.
55+
/**
56+
* @desc No-op referral seam listener for invitation acceptance events (P8a).
57+
* Proves the cross-module event contract end-to-end; credit-grant logic lands in #5.
58+
* @param {{invitationId: string, email: string, invitedBy: (string|null), acceptedUserId: string}} payload - Accepted invitation event payload.
59+
* @returns {void}
60+
*/
61+
// eslint-disable-next-line no-unused-vars
62+
invitationEvents.on('invitation.accepted', (payload) => {
63+
// TODO(#5): grant referral credits to payload.invitedBy (skip when invitedBy is null).
64+
// TODO(#5): async grant listener must self-guard rejections — the emit-site try/catch only catches sync throws.
65+
// No-op for P8a — the seam is the deliverable, not the grant.
66+
});
67+
4768
// Update analytics group properties when a subscription plan changes
4869
billingEvents.on('plan.changed', ({ organizationId, newPlan }) => {
4970
try {

modules/billing/tests/billing.init.unit.tests.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('billing.init unit tests:', () => {
1313
let mockDistinct;
1414
let mockMongoose;
1515
let mockLogger;
16+
let mockInvitationEvents;
1617

1718
const mockApp = {};
1819

@@ -58,6 +59,13 @@ describe('billing.init unit tests:', () => {
5859
default: { on: jest.fn(), emit: jest.fn() },
5960
}));
6061

62+
// P8a: billing is an optional consumer of the invitations `invitation.accepted`
63+
// event — stub the singleton so the unit test asserts the listener wiring in isolation.
64+
mockInvitationEvents = { on: jest.fn(), emit: jest.fn() };
65+
jest.unstable_mockModule('../../invitations/lib/events.js', () => ({
66+
default: mockInvitationEvents,
67+
}));
68+
6169
// Stub billing.email so boot validator tests don't wire real email listeners
6270
jest.unstable_mockModule('../billing.email.js', () => ({
6371
setupBillingEmails: jest.fn(),
@@ -79,6 +87,17 @@ describe('billing.init unit tests:', () => {
7987
await expect(billingInit(mockApp)).resolves.toBeUndefined();
8088
});
8189

90+
test('P8a: wires a (no-op) invitation.accepted listener that does not throw on emit', async () => {
91+
await billingInit(mockApp);
92+
// The seam is proven by the listener being registered on the invitations emitter.
93+
const acceptedCall = mockInvitationEvents.on.mock.calls.find(([evt]) => evt === 'invitation.accepted');
94+
expect(acceptedCall).toBeDefined();
95+
const handler = acceptedCall[1];
96+
expect(typeof handler).toBe('function');
97+
// No-op: invoking it with a payload must not throw (and returns nothing).
98+
expect(() => handler({ invitationId: 'i1', email: 'a@b.co', invitedBy: 'x', acceptedUserId: 'u1' })).not.toThrow();
99+
});
100+
82101
test('resolves without error when meterMode=true and no legacy docs', async () => {
83102
mockConfig.billing.meterMode = true;
84103
mockConfig.billing.plans = ['free', 'growth', 'pro'];

modules/invitations/invitations.init.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,25 @@ export default async () => {
8080
}
8181
if (!invite) return undefined;
8282
// Return the resolved (+claimed, local) invite plus finalize/release closures
83-
// bound to its id. finalize/release logic stays in this module; auth just relays.
83+
// bound to it. The accept/release logic stays in this module; auth just relays.
84+
// P8a: `finalize` now routes through InvitationsService.accept, which finalizes
85+
// the invite AND wires the referral substrate (#5) — stamps referredBy on the new
86+
// user (server-side) + emits `invitation.accepted`. The closure name stays
87+
// `finalize` so auth.controller relays it unchanged (auth never imports us); accept
88+
// is a superset of finalize. Fires on BOTH the token AND the OAuth path (both go
89+
// through this same closure), so OAuth-invited users are credited too.
90+
91+
/**
92+
* @desc Finalize accepted invite and run referral side-effects (P8a).
93+
* Delegates to InvitationsService.accept so auth stays import-free.
94+
* @param {String} userId - the just-created user id
95+
* @returns {Promise<Object|null>} finalized invitation document, or null if not finalized
96+
*/
97+
const finalizeInvite = (userId) => InvitationsService.accept(invite, userId);
98+
8499
return {
85100
invite,
86-
finalize: (userId) => InvitationsService.finalize(invite.id, userId),
101+
finalize: finalizeInvite,
87102
release: () => InvitationsService.release(invite.id),
88103
};
89104
});

modules/invitations/lib/events.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,27 @@ import { EventEmitter } from 'events';
77
* Singleton emitter for invitation events. Config-free / import-safe.
88
*
99
* Events:
10-
* - `invitation.accepted` — emitted when an invite is consumed by a signup
11-
* Payload: { invitationId, email, invitedBy, acceptedUserId }
10+
* - `invitation.accepted` — emitted (P8a) by InvitationsService.accept when an invite
11+
* is consumed by a successful signup (BOTH the local two-phase token path AND the
12+
* OAuth-by-email path go through the same accept seam). Fire-and-forget. Always
13+
* emitted on accept.
14+
* ⚠️ The try/catch around the `emit` call (accept seam) only guards against a
15+
* SYNCHRONOUS listener throw — `EventEmitter.emit` is synchronous, so it returns
16+
* before any async listener settles. An ASYNC listener (e.g. `async (p) => { await
17+
* grantCredits() }`) that REJECTS escapes the emit-site try/catch as an
18+
* unhandledRejection AFTER emit returns. Therefore a future async listener (e.g. the
19+
* #5 credit-grant) MUST own its own internal try/catch and never let a rejection
20+
* escape, OR the emit seam must switch to an awaited `Promise.allSettled` fan-out —
21+
* the current synchronous guard will NOT catch an async rejection.
22+
* Payload: {
23+
* invitationId: String — the accepted invite's id
24+
* email: String — the invite's pinned (lowercased) email
25+
* invitedBy: ObjectId|null — the inviter user id, or null for an admin-created
26+
* invite with no inviter
27+
* acceptedUserId: String — the just-created user's id (= the new user's referredBy
28+
* source when invitedBy is set)
29+
* }
1230
*
13-
* NOTE: no event is emitted yet — P8 wires the actual `invitation.accepted` emit.
1431
* This file ships the singleton + the error-listener hook (registered in
1532
* invitations.init.js after config is ready) so it stays config-free and
1633
* importable without ordering hazards (mirrors billing/lib/events.js).

modules/invitations/services/invitations.service.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import crypto from 'crypto';
66
import InvitationRepository from '../repositories/invitations.repository.js';
77
import { DEFAULT_INVITE_EXPIRES_IN_DAYS, STALE_CLAIM_MINUTES } from '../lib/constants.js';
88
import UserService from '../../users/services/users.service.js';
9+
import invitationEvents from '../lib/events.js';
910
import config from '../../../config/index.js';
1011
import mails from '../../../lib/helpers/mailer/index.js';
1112
import getBaseUrl from '../../../lib/helpers/getBaseUrl.js';
@@ -201,6 +202,79 @@ const finalize = async (id, userId) => {
201202
return result;
202203
};
203204

205+
/**
206+
* @desc P8a — the shared ACCEPT seam, invoked by the eligibility closure on FULL
207+
* signup success for BOTH paths (local two-phase token AND OAuth-by-email). It:
208+
* 1. finalizes the invite (status:'accepted', acceptedAt, acceptedUserId:userId,
209+
* usedAt — burns single-use; already idempotent on acceptedAt:null in the repo),
210+
* 2. stamps `referredBy = invite.invitedBy` on the just-created user, SERVER-SIDE,
211+
* via UserService.updateById (raw update — bypasses the client whitelist + Zod,
212+
* so this is the ONLY way the field is ever written; never from a client body).
213+
* invitations already depends on users (the E9 guard), so this keeps auth import-free.
214+
* 3. emits `invitation.accepted` so optional consumers (billing #5 credit-grant) can
215+
* react fire-and-forget.
216+
*
217+
* Referral substrate (#5) — NO credit-grant logic here; this only wires the field + event.
218+
*
219+
* `invitedBy` may be null (admin-created invite with no inviter). We DECIDE to ALWAYS
220+
* emit on accept (the canonical "an invite was consumed" signal) with `invitedBy:null`
221+
* in that case, but to SKIP the referredBy write when there is no inviter (leaving the
222+
* user's `referredBy` at its `default:null` — writing null is a redundant no-op).
223+
*
224+
* If finalize() returns null (duplicate-accept / revoked / missing invite), the method
225+
* returns null immediately — no referral side-effects fire for non-finalized invites.
226+
*
227+
* Both side-effects are best-effort: a referredBy-write failure or a listener throw must
228+
* NOT roll back an already-burned invite or break the signup response (the invite is
229+
* finalized first; the referral wiring is downstream of account creation). Errors are
230+
* logged. The emit is wrapped because a synchronous listener throw would otherwise
231+
* propagate out of `emit` into the signup flow.
232+
*
233+
* @param {Object} invite - the resolved invite doc (has id, invitedBy, email)
234+
* @param {String} userId - the just-created user's id
235+
* @returns {Promise<Object|null>} the finalized invite doc, or null if nothing to finalize
236+
*/
237+
const accept = async (invite, userId) => {
238+
const result = await finalize(invite.id, userId);
239+
// Guard: only wire referral side-effects when the invite was actually finalized.
240+
// finalize() returns null on duplicate-accept / revoked / missing — we must not
241+
// emit a consumed-invite signal or stamp referredBy for an invite that did not land.
242+
if (!result) {
243+
return null;
244+
}
245+
const invitedBy = invite.invitedBy || null;
246+
// Stamp the referral link server-side (skip the redundant null write).
247+
if (invitedBy) {
248+
try {
249+
await UserService.updateById(userId, { referredBy: invitedBy });
250+
} catch (err) {
251+
// Best-effort: the invite is already accepted; a referredBy write failure must
252+
// not break signup. Surface it as a warning for follow-up (referral attribution
253+
// would be lost for this user, but the account is valid).
254+
logger.warn('invitations: failed to set referredBy on accepted signup', {
255+
invitationId: String(invite.id),
256+
userId: String(userId),
257+
invitedBy: String(invitedBy),
258+
message: err?.message,
259+
});
260+
}
261+
}
262+
// Always emit on accept (invitedBy may be null) so the event is the single canonical
263+
// "invite consumed" signal. Guard the emit so a synchronous listener throw cannot
264+
// escape into the signup flow (an emitted 'error' would crash without the init listener).
265+
try {
266+
invitationEvents.emit('invitation.accepted', {
267+
invitationId: invite.id,
268+
email: invite.email,
269+
invitedBy,
270+
acceptedUserId: userId,
271+
});
272+
} catch (err) {
273+
logger.warn('invitations: invitation.accepted listener threw', { message: err?.message });
274+
}
275+
return result;
276+
};
277+
204278
/**
205279
* @desc E2 — release a claimed-but-unfinalized invite (on any pre-response signup
206280
* failure) so it can be retried. Checks the repository return.
@@ -244,6 +318,7 @@ export default {
244318
assertInvitedByEmail,
245319
claim,
246320
finalize,
321+
accept,
247322
release,
248323
sweepStaleClaims,
249324
list,

modules/invitations/tests/invitations.init.unit.tests.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const mockService = {
2020
assertInvitedByEmail: jest.fn(),
2121
claim: jest.fn(),
2222
finalize: jest.fn(),
23+
accept: jest.fn(),
2324
release: jest.fn(),
2425
sweepStaleClaims: jest.fn(),
2526
};
@@ -74,9 +75,13 @@ describe('invitations.init', () => {
7475
// E2: the resolved invite was atomically claimed by token before returning.
7576
expect(mockService.claim).toHaveBeenCalledWith('tok');
7677
expect(result.invite).toBe(invite);
77-
// returned finalize/release closures bind exactly this invite id
78+
// returned finalize/release closures bind exactly this invite. P8a: the `finalize`
79+
// closure routes through accept(invite, userId) (finalize + referral wiring); the
80+
// closure name is unchanged so auth relays it verbatim. accept receives the WHOLE
81+
// invite (needs invitedBy/email for referredBy + the event payload), not just the id.
7882
await result.finalize('u9');
79-
expect(mockService.finalize).toHaveBeenCalledWith('i7', 'u9');
83+
expect(mockService.accept).toHaveBeenCalledWith(invite, 'u9');
84+
expect(mockService.finalize).not.toHaveBeenCalled();
8085
await result.release();
8186
expect(mockService.release).toHaveBeenCalledWith('i7');
8287
});
@@ -117,8 +122,10 @@ describe('invitations.init', () => {
117122
// OAuth has no token to claim — claim must NOT be invoked on this path.
118123
expect(mockService.claim).not.toHaveBeenCalled();
119124
expect(result.invite).toBe(invite);
125+
// P8a: the OAuth path's finalize closure also routes through accept — so an
126+
// OAuth-invited user gets referredBy set + invitation.accepted emitted too.
120127
await result.finalize('u2');
121-
expect(mockService.finalize).toHaveBeenCalledWith('o1', 'u2');
128+
expect(mockService.accept).toHaveBeenCalledWith(invite, 'u2');
122129
});
123130

124131
test('(c) OAuth: does NOT resolve an invite when the provider email is unverified (E7)', async () => {

modules/invitations/tests/invitations.integration.tests.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,105 @@ describe('Signup invitations:', () => {
623623
});
624624
});
625625

626+
describe('P8a referral substrate (referredBy + invitation.accepted)', () => {
627+
let invitationEvents;
628+
let originalUp; let originalCap;
629+
630+
beforeAll(async () => {
631+
invitationEvents = (await import(path.resolve('./modules/invitations/lib/events.js'))).default;
632+
});
633+
634+
beforeEach(() => { originalUp = config.sign.up; originalCap = config.sign.cap; });
635+
afterEach(async () => {
636+
config.sign.up = originalUp; config.sign.cap = originalCap;
637+
jest.restoreAllMocks();
638+
for (const email of ['p8a-token@example.com', 'p8a-oauth@example.com', 'p8a-event@example.com']) {
639+
try {
640+
const existing = await UserService.getBrut({ email });
641+
if (existing) await UserService.remove(existing);
642+
} catch (_) { /* cleanup */ }
643+
}
644+
});
645+
646+
test('token path: invited signup sets referredBy = inviter id; a client-supplied referredBy in the body is IGNORED', async () => {
647+
const adminAgent = await createAdminAndSignin();
648+
const email = 'p8a-token@example.com';
649+
const created = await adminAgent.post('/api/invitations').send({ email });
650+
const { token } = created.body.data;
651+
// The inviter is the admin who created the invite.
652+
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
653+
const inviterId = String(admin._id);
654+
655+
config.sign.up = false; config.sign.cap = null;
656+
657+
// Attacker tries to self-assign a DIFFERENT referrer via the signup body.
658+
const res = await request(app)
659+
.post(`/api/auth/signup?inviteToken=${token}`)
660+
.send({ email, password: 'Sup3rStr0ng!', referredBy: '64b2f0000000000000000999' });
661+
expect(res.status).toBe(200);
662+
663+
const brut = await UserService.getBrut({ email });
664+
// referredBy is the SERVER-resolved inviter, NOT the client-supplied id.
665+
expect(String(brut.referredBy)).toBe(inviterId);
666+
expect(String(brut.referredBy)).not.toBe('64b2f0000000000000000999');
667+
});
668+
669+
test('OAuth path: an email-matched invited OAuth signup ALSO sets referredBy = inviter id', async () => {
670+
const AuthController = (await import(path.resolve('./modules/auth/controllers/auth.controller.js'))).default;
671+
const adminAgent = await createAdminAndSignin();
672+
const email = 'p8a-oauth@example.com';
673+
await adminAgent.post('/api/invitations').send({ email });
674+
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
675+
const inviterId = String(admin._id);
676+
677+
config.sign.up = false; config.sign.cap = null;
678+
679+
const profil = {
680+
firstName: 'Referred', // NB: the name Zod regex rejects digits — no 'P8a'
681+
lastName: 'OAuth',
682+
email,
683+
avatar: '',
684+
providerData: { sub: 'google-p8a-oauth-sub' },
685+
emailVerifiedByProvider: true,
686+
};
687+
const createdUser = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');
688+
expect(createdUser.email).toBe(email);
689+
690+
const brut = await UserService.getBrut({ email });
691+
// OAuth-invited users are credited too (the same accept seam runs).
692+
expect(String(brut.referredBy)).toBe(inviterId);
693+
});
694+
695+
test('invitation.accepted fires with the documented payload on accept (proves the billing listener seam end-to-end)', async () => {
696+
const adminAgent = await createAdminAndSignin();
697+
const email = 'p8a-event@example.com';
698+
const created = await adminAgent.post('/api/invitations').send({ email });
699+
const { token } = created.body.data;
700+
const invitationId = created.body.data.id;
701+
const admin = await UserService.getBrut({ email: 'inv-admin@test.com' });
702+
const inviterId = String(admin._id);
703+
704+
// Spy on the REAL events singleton (billing's no-op listener is also attached
705+
// to it at boot — this proves the event reaches consumers end-to-end).
706+
const emitSpy = jest.spyOn(invitationEvents, 'emit');
707+
708+
config.sign.up = false; config.sign.cap = null;
709+
const res = await request(app)
710+
.post(`/api/auth/signup?inviteToken=${token}`)
711+
.send({ email, password: 'Sup3rStr0ng!' });
712+
expect(res.status).toBe(200);
713+
const newUser = await UserService.getBrut({ email });
714+
715+
const acceptedCall = emitSpy.mock.calls.find(([evt]) => evt === 'invitation.accepted');
716+
expect(acceptedCall).toBeDefined();
717+
const payload = acceptedCall[1];
718+
expect(payload.invitationId).toBe(invitationId);
719+
expect(payload.email).toBe(email);
720+
expect(String(payload.invitedBy)).toBe(inviterId);
721+
expect(String(payload.acceptedUserId)).toBe(String(newUser._id));
722+
});
723+
});
724+
626725
describe('E4 cap unify — blank cap means UNCAPPED at the signup gate', () => {
627726
let originalUp; let originalCap;
628727
beforeEach(() => { originalUp = config.sign.up; originalCap = config.sign.cap; });

0 commit comments

Comments
 (0)