diff --git a/config/defaults/production.config.js b/config/defaults/production.config.js
index ba1094646..f5769e37d 100644
--- a/config/defaults/production.config.js
+++ b/config/defaults/production.config.js
@@ -62,6 +62,16 @@ const config = {
standardHeaders: true,
legacyHeaders: false,
},
+ // Authenticated POST /api/invitations. Stricter prod cap to harden against
+ // mass-invite abuse once `invitations.userFacing` widens create beyond admins
+ // (see invitations.development.config.js for the base-layer profile).
+ invitationsCreate: {
+ windowMs: 15 * 60 * 1000,
+ max: 20,
+ message: { message: 'Too many requests, please try again later.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ },
},
log: {
format: 'custom',
diff --git a/config/defaults/test.config.js b/config/defaults/test.config.js
index 0a835c017..f6f35572e 100644
--- a/config/defaults/test.config.js
+++ b/config/defaults/test.config.js
@@ -43,6 +43,9 @@ const config = {
publicImage: {
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
},
+ invitationsCreate: {
+ max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
+ },
},
uploads: {
avatar: {
diff --git a/config/templates/referral-reward-earned.html b/config/templates/referral-reward-earned.html
new file mode 100644
index 000000000..71b49828a
--- /dev/null
+++ b/config/templates/referral-reward-earned.html
@@ -0,0 +1,12 @@
+
+
+
+ {{appName}} referral reward
+
+
+ Hello {{displayName}},
+ Great news — someone you invited just joined {{appName}}, and you've earned a referral reward of {{units}} units.
+ Thanks for helping grow {{appName}}.
+ The {{appName}} Team.
+
+
diff --git a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js
index c63fa361e..1644673b5 100644
--- a/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js
+++ b/lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js
@@ -6,6 +6,7 @@ import organizationsDevConfig from '../../../modules/organizations/config/organi
import billingDevConfig from '../../../modules/billing/config/billing.development.config.js';
import authDevConfig from '../../../modules/auth/config/auth.development.config.js';
import uploadsDevConfig from '../../../modules/uploads/config/uploads.development.config.js';
+import invitationsDevConfig from '../../../modules/invitations/config/invitations.development.config.js';
/**
* Config-layering regression guard for the rate-limiter env-gate defect.
@@ -58,4 +59,12 @@ describe('rate-limiter base-layer profiles (env-gate config-layering):', () => {
// the base layer so the limiter is active under every NODE_ENV, not only prod.
expectUsableProfile(uploadsDevConfig.rateLimit.publicImage);
});
+
+ test('invitationsCreate profile lives in the invitations base layer (always merges)', () => {
+ // Guards POST /api/invitations (+ the /api/auth/invitations alias). Once
+ // `invitations.userFacing` (#3945) widens create beyond admins, an unbounded
+ // create is a DB-bloat / outbound-email-spam abuse surface — the profile must
+ // be present under every NODE_ENV, not only prod.
+ expectUsableProfile(invitationsDevConfig.rateLimit.invitationsCreate);
+ });
});
diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js
index 391b2fcb2..bf195cc2f 100644
--- a/modules/auth/controllers/auth.controller.js
+++ b/modules/auth/controllers/auth.controller.js
@@ -220,7 +220,17 @@ const signup = async (req, res) => {
AnalyticsService.capture({
distinctId: String(user.id),
event: 'user_signed_up',
- properties: { email: user.email, plan: user.plan, createdAt: user.createdAt },
+ properties: {
+ email: user.email,
+ plan: user.plan,
+ createdAt: user.createdAt,
+ // #3945: carry invite/referral attribution on the signup event so the
+ // referral funnel is measurable. `invite` is the resolved (opaque) result
+ // from the eligibility registry — already in scope, no invitations import.
+ invited: Boolean(invite),
+ invitationId: invite ? String(invite.id) : null,
+ invitedBy: invite?.invitedBy ? String(invite.invitedBy) : null,
+ },
});
} catch (_) { /* analytics must not break auth */ }
diff --git a/modules/auth/routes/auth.routes.js b/modules/auth/routes/auth.routes.js
index 8d78b9090..ef82302ef 100644
--- a/modules/auth/routes/auth.routes.js
+++ b/modules/auth/routes/auth.routes.js
@@ -29,6 +29,10 @@ import InvitationSchema from '../../invitations/models/invitations.schema.js';
*/
export default (app) => {
const authLimiter = limiters.auth;
+ // #3945: mirrors the same profile applied on the canonical mount
+ // (invitations.routes.js) — this alias points at the SAME controller.create, so
+ // leaving it unlimited would let a caller bypass the canonical route's cap.
+ const createLimiter = limiters.invitationsCreate;
// Signup invitations — DEPRECATION ALIAS for the canonical /api/invitations mount
// (modules/invitations). MUST be declared before the greedy `/api/auth/:strategy`
@@ -42,7 +46,7 @@ export default (app) => {
.route('/api/auth/invitations')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
.get(invitations.list)
- .post(model.isValid(InvitationSchema.Invitation), invitations.create);
+ .post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create);
app
.route('/api/auth/invitations/:invitationId')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
diff --git a/modules/auth/tests/auth.silent.catch.unit.tests.js b/modules/auth/tests/auth.silent.catch.unit.tests.js
index e1accd48f..9b89cf3ff 100644
--- a/modules/auth/tests/auth.silent.catch.unit.tests.js
+++ b/modules/auth/tests/auth.silent.catch.unit.tests.js
@@ -329,6 +329,276 @@ describe('auth.controller signup mass-assignment strip:', () => {
});
});
+describe('auth.controller signup analytics: invite/referral attribution (#3945):', () => {
+ beforeEach(() => {
+ jest.resetModules();
+
+ jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
+ default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
+ }));
+ });
+
+ test('user_signed_up carries invited:true + invitationId + invitedBy when the eligibility registry resolved an invite', async () => {
+ const mockCreate = jest.fn().mockResolvedValue({
+ id: 'u1', email: 'invitee@y.com', firstName: 'A', lastName: 'B', provider: 'local',
+ });
+
+ jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
+ default: {
+ create: mockCreate,
+ getBrut: jest.fn().mockResolvedValue({ id: 'u1' }),
+ update: jest.fn().mockResolvedValue({}),
+ remove: jest.fn(),
+ count: jest.fn().mockResolvedValue(0),
+ },
+ }));
+
+ // Closed-signup, invite-gated path: the eligibility registry resolves + claims
+ // the invite and returns { invite, finalize, release } — auth relays it verbatim.
+ jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
+ default: {
+ registerSignupEligibility: jest.fn(),
+ assertSignupEligible: jest.fn().mockResolvedValue({
+ invite: { id: 'inv1', email: 'invitee@y.com', invitedBy: 'inviter1' },
+ finalize: jest.fn().mockResolvedValue({ id: 'inv1', status: 'accepted' }),
+ release: jest.fn(),
+ }),
+ _reset: jest.fn(),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
+ default: {
+ handleSignupOrganization: jest.fn().mockResolvedValue({
+ organization: null, joined: false, pendingJoin: false,
+ abilities: [], organizationSetupRequired: false,
+ emailVerificationRequired: false, suggestedOrganization: null,
+ }),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
+ default: { autoSetCurrentOrganization: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
+ default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
+ }));
+
+ jest.unstable_mockModule('../../../config/index.js', () => ({
+ default: {
+ sign: { up: false, in: true }, // closed signup — invite is required to open the gate
+ jwt: { secret: 'test-secret', expiresIn: 3600 },
+ cookie: { secure: false, sameSite: 'lax' },
+ organizations: { enabled: false },
+ app: { title: 'Test', contact: 'test@test.com' },
+ },
+ }));
+
+ jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
+ default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
+ default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
+ default: {
+ success: jest.fn().mockReturnValue(jest.fn()),
+ error: jest.fn().mockReturnValue(jest.fn()),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
+ default: { getMessage: jest.fn().mockReturnValue('error') },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
+ default: class AppError extends Error {
+ constructor(msg, opts) {
+ super(msg);
+ this.status = opts?.status;
+ this.code = opts?.code;
+ this.details = opts?.details;
+ }
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
+ default: { User: {}, SignupUser: {} },
+ }));
+
+ jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
+ default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
+ default: jest.fn().mockReturnValue([]),
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
+ default: jest.fn().mockReturnValue('http://localhost:3000'),
+ }));
+
+ const mockCapture = jest.fn();
+ jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
+ default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
+ }));
+
+ const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
+
+ const req = {
+ body: { email: 'invitee@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' },
+ query: { inviteToken: 'tok' },
+ };
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ cookie: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await AuthController.signup(req, res);
+
+ expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
+ distinctId: 'u1',
+ event: 'user_signed_up',
+ properties: expect.objectContaining({
+ invited: true,
+ invitationId: 'inv1',
+ invitedBy: 'inviter1',
+ }),
+ }));
+ });
+
+ test('user_signed_up carries invited:false + null invitationId/invitedBy on a non-invited (open) signup', async () => {
+ const mockCreate = jest.fn().mockResolvedValue({
+ id: 'u2', email: 'self@y.com', firstName: 'C', lastName: 'D', provider: 'local',
+ });
+
+ jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
+ default: {
+ create: mockCreate,
+ getBrut: jest.fn().mockResolvedValue({ id: 'u2' }),
+ update: jest.fn().mockResolvedValue({}),
+ remove: jest.fn(),
+ count: jest.fn().mockResolvedValue(0),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
+ default: {
+ registerSignupEligibility: jest.fn(),
+ assertSignupEligible: jest.fn().mockResolvedValue(undefined), // no invite opened the gate
+ _reset: jest.fn(),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
+ default: {
+ handleSignupOrganization: jest.fn().mockResolvedValue({
+ organization: null, joined: false, pendingJoin: false,
+ abilities: [], organizationSetupRequired: false,
+ emailVerificationRequired: false, suggestedOrganization: null,
+ }),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
+ default: { autoSetCurrentOrganization: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
+ default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) },
+ }));
+
+ jest.unstable_mockModule('../../../config/index.js', () => ({
+ default: {
+ sign: { up: true, in: true }, // open signup — no invite required
+ jwt: { secret: 'test-secret', expiresIn: 3600 },
+ cookie: { secure: false, sameSite: 'lax' },
+ organizations: { enabled: false },
+ app: { title: 'Test', contact: 'test@test.com' },
+ },
+ }));
+
+ jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
+ default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
+ default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
+ default: {
+ success: jest.fn().mockReturnValue(jest.fn()),
+ error: jest.fn().mockReturnValue(jest.fn()),
+ },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
+ default: { getMessage: jest.fn().mockReturnValue('error') },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
+ default: class AppError extends Error {
+ constructor(msg, opts) {
+ super(msg);
+ this.status = opts?.status;
+ this.code = opts?.code;
+ this.details = opts?.details;
+ }
+ },
+ }));
+
+ jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
+ default: { User: {}, SignupUser: {} },
+ }));
+
+ jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
+ default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
+ default: jest.fn().mockReturnValue([]),
+ }));
+
+ jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
+ default: jest.fn().mockReturnValue('http://localhost:3000'),
+ }));
+
+ const mockCapture = jest.fn();
+ jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
+ default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
+ }));
+
+ const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
+
+ const req = {
+ body: { email: 'self@y.com', firstName: 'C', lastName: 'D', password: 'P@ss1234!' },
+ query: {},
+ };
+ const res = {
+ status: jest.fn().mockReturnThis(),
+ cookie: jest.fn().mockReturnThis(),
+ json: jest.fn().mockReturnThis(),
+ };
+
+ await AuthController.signup(req, res);
+
+ expect(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
+ distinctId: 'u2',
+ event: 'user_signed_up',
+ properties: expect.objectContaining({
+ invited: false,
+ invitationId: null,
+ invitedBy: null,
+ }),
+ }));
+ });
+});
+
describe('auth.password.controller silent-catch error logging:', () => {
let mockWarn;
let mockError;
diff --git a/modules/billing/services/billing.referral.service.js b/modules/billing/services/billing.referral.service.js
index 33898f1e0..db62c6802 100644
--- a/modules/billing/services/billing.referral.service.js
+++ b/modules/billing/services/billing.referral.service.js
@@ -3,6 +3,7 @@
*/
import config from '../../../config/index.js';
import logger from '../../../lib/services/logger.js';
+import mailer from '../../../lib/helpers/mailer/index.js';
import BillingExtraBalanceRepository from '../repositories/billing.extraBalance.repository.js';
/**
@@ -145,6 +146,47 @@ const grantSide = async ({ userId, units, key, expiresAt }) => {
return { applied: false, reason: result.reason ?? 'duplicate_grant', organizationId };
};
+/**
+ * @function notifyReferrer
+ * @description Best-effort email to the referrer once their referral reward is
+ * CREDITED (#3945) — today the grant is a silent ledger credit; this closes
+ * that gap by reusing the stack's EXISTING mailer abstraction (mirrors
+ * org-request-approved / org-member-added in
+ * organizations.membership.service.js), not a new notification channel.
+ * Called ONLY when grantSide reports `applied:true` for the referrer side —
+ * the repository's atomic refId dedup guard means that happens exactly ONCE
+ * per invitation, so a reconcile-cron back-fill or a replayed event can never
+ * double-notify. Self-guarded: a mailer/lookup failure is logged and never
+ * propagates — the grant itself already landed and must not be affected.
+ * @param {string} userId - The referrer's user id.
+ * @param {number} units - Units credited to the referrer (config-driven).
+ * @returns {Promise}
+ */
+// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
+const notifyReferrer = async (userId, units) => {
+ try {
+ if (!mailer.isConfigured()) return;
+ const { default: UserService } = await import('../../users/services/users.service.js');
+ const user = await UserService.getBrut({ id: String(userId) });
+ if (!user?.email) return;
+ await mailer.sendMail({
+ to: user.email,
+ subject: `You earned a referral reward — ${config.app.title}`,
+ template: 'referral-reward-earned',
+ params: {
+ displayName: [user.firstName, user.lastName].filter(Boolean).join(' '),
+ units,
+ appName: config.app.title,
+ },
+ });
+ } catch (err) {
+ logger.warn('[billing.referral] referrer notification failed (non-fatal)', {
+ userId: String(userId),
+ message: err?.message,
+ });
+ }
+};
+
/**
* @function grantForInvitation
* @description Apply the standard referral grant for one accepted invitation:
@@ -159,6 +201,9 @@ const grantSide = async ({ userId, units, key, expiresAt }) => {
* reconcile cron) — the branches below only label WHY an absent side was
* skipped (observability). Each side is keyed
* `referral::` so replays can never double-credit.
+ * A freshly APPLIED referrer grant (first successful credit for this
+ * invitation) also triggers a best-effort referrer notification email
+ * (#3945, notifyReferrer) — never on a duplicate/idempotent replay.
* May reject on infrastructure errors — callers own their failure handling
* (the listener self-guards, the cron counts errors).
* @param {Object} payload - The `invitation.accepted` payload (or its cron reconstruction).
@@ -195,6 +240,11 @@ const grantForInvitation = async ({ invitationId, invitedBy, acceptedUserId } =
// Referrer side — skip actor-less invites and trivial self-referrals (#3833 owns the full guard).
if (expected.has('referrer')) {
result.referrer = await grantSide({ userId: invitedBy, units: cfg.referrerUnits, key: expected.get('referrer'), expiresAt });
+ // #3945: notify ONLY on a freshly applied grant — never on an idempotent replay
+ // (grantSide's atomic dedup guard means `applied:true` fires exactly once).
+ if (result.referrer.applied) {
+ await notifyReferrer(invitedBy, cfg.referrerUnits);
+ }
} else if (!invitedBy) {
result.referrer = { applied: false, reason: 'no_inviter' };
} else if (String(invitedBy) === String(acceptedUserId)) {
diff --git a/modules/billing/tests/billing.referral.service.unit.tests.js b/modules/billing/tests/billing.referral.service.unit.tests.js
index 3d7b2ec89..a145db886 100644
--- a/modules/billing/tests/billing.referral.service.unit.tests.js
+++ b/modules/billing/tests/billing.referral.service.unit.tests.js
@@ -23,6 +23,7 @@ describe('billing.referral.service unit tests:', () => {
let mockUserService;
let mockMembershipRepository;
let mockLogger;
+ let mockMailer;
const invitationId = '64b2f0000000000000000001';
const inviterId = '64b2f0000000000000000010';
@@ -34,6 +35,7 @@ describe('billing.referral.service unit tests:', () => {
jest.resetModules();
mockConfig = {
+ app: { title: 'Test App' },
billing: {
referral: { enabled: true, referrerUnits: 1000, refereeUnits: 500, expiryDays: 365 },
},
@@ -46,7 +48,7 @@ describe('billing.referral.service unit tests:', () => {
// Users keyed by id — getBrut({ id }) resolves from this map.
const users = {
- [inviterId]: { _id: inviterId, currentOrganization: inviterOrgId },
+ [inviterId]: { _id: inviterId, currentOrganization: inviterOrgId, email: 'inviter@example.com', firstName: 'In', lastName: 'Viter' },
[refereeId]: { _id: refereeId, currentOrganization: refereeOrgId },
};
mockUserService = {
@@ -56,9 +58,13 @@ describe('billing.referral.service unit tests:', () => {
mockMembershipRepository = { findOne: jest.fn().mockResolvedValue(null) };
mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
+ // Mailer OFF by default (matches the mailer.isConfigured() gate default in most
+ // deployments) — tests that need to assert notifyReferrer's email flip it ON.
+ mockMailer = { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn().mockResolvedValue({}) };
jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig }));
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger }));
+ jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ default: mockMailer }));
jest.unstable_mockModule('../repositories/billing.extraBalance.repository.js', () => ({ default: mockRepository }));
jest.unstable_mockModule('../../users/services/users.service.js', () => ({ default: mockUserService }));
jest.unstable_mockModule('../../organizations/repositories/organizations.membership.repository.js', () => ({
@@ -259,4 +265,63 @@ describe('billing.referral.service unit tests:', () => {
expect(result).toEqual({ skipped: 'no_invitation_id' });
expect(mockRepository.creditGrant).not.toHaveBeenCalled();
});
+
+ describe('notifyReferrer (#3945 referrer email on a freshly applied grant)', () => {
+ test('mailer OFF (default in this suite) → no email lookup, no send', async () => {
+ await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId });
+ expect(mockMailer.sendMail).not.toHaveBeenCalled();
+ });
+
+ test('mailer ON + referrer grant freshly applied → sends the referral-reward-earned email to the referrer', async () => {
+ mockMailer.isConfigured.mockReturnValue(true);
+
+ await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId });
+
+ expect(mockMailer.sendMail).toHaveBeenCalledTimes(1);
+ const mail = mockMailer.sendMail.mock.calls[0][0];
+ expect(mail.to).toBe('inviter@example.com');
+ expect(mail.template).toBe('referral-reward-earned');
+ expect(mail.params).toMatchObject({ units: 1000, appName: 'Test App', displayName: 'In Viter' });
+ });
+
+ test('mailer ON but referrer side not applied (e.g. self-referral) → no email', async () => {
+ mockMailer.isConfigured.mockReturnValue(true);
+
+ await BillingReferralService.grantForInvitation({ invitationId, invitedBy: refereeId, acceptedUserId: refereeId });
+
+ expect(mockMailer.sendMail).not.toHaveBeenCalled();
+ });
+
+ test('mailer ON but referrer grant is a duplicate replay (applied:false) → no email (never double-notifies)', async () => {
+ mockMailer.isConfigured.mockReturnValue(true);
+ mockRepository.creditGrant.mockResolvedValue({ doc: null, applied: false, reason: 'duplicate_grant' });
+
+ await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId });
+
+ expect(mockMailer.sendMail).not.toHaveBeenCalled();
+ });
+
+ test('mailer ON but the referrer user has no email → no send, no throw', async () => {
+ mockMailer.isConfigured.mockReturnValue(true);
+ mockUserService._users[inviterId] = { _id: inviterId, currentOrganization: inviterOrgId }; // no email
+
+ const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId });
+
+ expect(mockMailer.sendMail).not.toHaveBeenCalled();
+ expect(result.referrer).toMatchObject({ applied: true });
+ });
+
+ test('a mailer send failure is swallowed — the grant result is unaffected, error is logged', async () => {
+ mockMailer.isConfigured.mockReturnValue(true);
+ mockMailer.sendMail.mockRejectedValue(new Error('SMTP down'));
+
+ const result = await BillingReferralService.grantForInvitation({ invitationId, invitedBy: inviterId, acceptedUserId: refereeId });
+
+ expect(result.referrer).toMatchObject({ applied: true, organizationId: inviterOrgId });
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ '[billing.referral] referrer notification failed (non-fatal)',
+ expect.objectContaining({ userId: inviterId, message: 'SMTP down' }),
+ );
+ });
+ });
});
diff --git a/modules/invitations/README.md b/modules/invitations/README.md
index fe077c32e..4a944bd6b 100644
--- a/modules/invitations/README.md
+++ b/modules/invitations/README.md
@@ -141,9 +141,17 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts
service: admins read the platform-global list; any other caller reads only the
invitations they sent (`invitedBy`-scoped — the `{ invitedBy: 1 }` index covers
it; a caller with no resolvable id gets `[]`, never the `invitedBy:null`
- admin-created rows). CASL still grants the route to admins only — widening the
- `Invitation` abilities to regular users is the referral phase's flip; the scoping
- ships first so that flip can never leak invitee emails (PII) platform-wide.
+ admin-created rows). **CASL widening — SHIPPED (#3945), config-gated**:
+ `config.invitations.userFacing` (stack default OFF — preserves existing
+ deployments) flips `invitationAbilities` to grant any authenticated user
+ `create` + `read` on `Invitation` (still TYPE-level; the real invitedBy-scoping
+ is the service list() above — see `invitations.policy.js` for why no
+ document-subject is registered). `resend` stays explicitly admin-gated in the
+ controller regardless of the flag (the POST→`create` method-mapping collision
+ with new-invitation creation). Also shipped: an `invitation_redeemed` analytics
+ event on accept, invite/referral properties on `user_signed_up`, and a
+ best-effort referrer notification email (`referral-reward-earned` template)
+ when a referral reward is freshly credited.
2. **Self-referral guard — SHIPPED (#3833), with a known residual**: `create()`
rejects 422 "You cannot invite yourself" when the invitee email equals the
inviter's own, before the E9 registered-email check. The grant-side floor
@@ -160,7 +168,9 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts
`billing.referral` there is a silent no-op. The Vue Referrals tab reads
`GET /api/auth/config` (`sign.up`) and replaces the invite form with an
informational state when signup is open (the referrals list stays read-only).
-4. **Index `referredBy`** alongside the first real referral query.
+4. **Index `referredBy` — SHIPPED (#3945)**: `{ referredBy: 1 }` on the `User` model,
+ added alongside the CASL widening above (the account Referrals view is the first
+ real referral query).
## UI
diff --git a/modules/invitations/config/invitations.development.config.js b/modules/invitations/config/invitations.development.config.js
index b9b42e12d..81c5d24d2 100644
--- a/modules/invitations/config/invitations.development.config.js
+++ b/modules/invitations/config/invitations.development.config.js
@@ -11,6 +11,35 @@ const config = {
sign: {
inviteExpiresInDays: 14, // signup invite link validity (days)
},
+ invitations: {
+ /**
+ * User-facing referral invitations (#3945) — stack default OFF, preserves
+ * existing deployments' admin-only behavior. When true, invitationAbilities
+ * grants any authenticated user `create` on Invitation (their own referral
+ * link) and `read` (scoped server-side to invitations THEY sent — see
+ * InvitationsService.list(), #3833); platform admins keep `manage all`
+ * regardless. Pair with `billing.referral.enabled` (billing.development.config.js)
+ * to actually reward accepted referrals — this flag only controls WHO can see/
+ * create invitations, not whether a reward is granted.
+ */
+ userFacing: false,
+ },
+ // #3945: POST /api/invitations already had no rate limiter under admin-only access
+ // (a trusted caller); with `invitations.userFacing` able to widen `create` to any
+ // authenticated user, an unbounded create is a DB-bloat / outbound-email-spam
+ // abuse surface (mirrors the verify/:token route, which already uses `limiters.auth`).
+ // Lives in this base layer so the profile is present — and the limiter active —
+ // under EVERY env, not only the literal `production`; a missing profile means a
+ // no-op limiter. Stricter cap applied in config/defaults/production.config.js.
+ rateLimit: {
+ invitationsCreate: {
+ windowMs: 15 * 60 * 1000, // 15 min
+ max: 200, // lenient in dev; production overrides to a stricter cap
+ message: { message: 'Too many requests, please try again later.' },
+ standardHeaders: true,
+ legacyHeaders: false,
+ },
+ },
};
export default config;
diff --git a/modules/invitations/controllers/invitations.controller.js b/modules/invitations/controllers/invitations.controller.js
index ee3a1304f..f19d22797 100644
--- a/modules/invitations/controllers/invitations.controller.js
+++ b/modules/invitations/controllers/invitations.controller.js
@@ -71,14 +71,23 @@ const remove = async (req, res) => {
};
/**
- * @desc Admin: re-send the invitation email for a pending invitation (existing token)
+ * @desc Admin: re-send the invitation email for a pending invitation (existing token).
+ * Explicitly admin-gated regardless of `config.invitations.userFacing` (#3945): CASL
+ * has no per-document Invitation subject registered, and this POST route resolves to
+ * the SAME 'create' action (methodToAction) as new-invitation creation — a widened
+ * non-admin 'create' grant would otherwise also open resend to ANY invitation, not just
+ * the caller's own. This is the defense-in-depth mirror (see invitations.policy.js).
* @param {Object} req - Express request object
+ * @param {Object} req.user - Authenticated caller
* @param {Object} req.invitation - Loaded invitation document (set by invitationByID middleware)
* @param {string} req.invitation.id - Invitation id
* @param {Object} res - Express response object
- * @returns {Promise} Sends HTTP 200 with the invitation, 409 when not pending, or 422 on error
+ * @returns {Promise} Sends HTTP 200 with the invitation, 403 when not admin, 409 when not pending, or 422 on error
*/
const resend = async (req, res) => {
+ if (!Array.isArray(req.user?.roles) || !req.user.roles.includes('admin')) {
+ return responses.error(res, 403, 'Forbidden', 'Only platform admins can resend invitations')();
+ }
try {
const invitation = await InvitationService.resend(req.invitation.id);
responses.success(res, 'invitation resent')(invitation);
diff --git a/modules/invitations/policies/invitations.policy.js b/modules/invitations/policies/invitations.policy.js
index 36f40a208..a348de4f2 100644
--- a/modules/invitations/policies/invitations.policy.js
+++ b/modules/invitations/policies/invitations.policy.js
@@ -1,6 +1,7 @@
/**
* Signup-invitation abilities for CASL document/path authorization.
*/
+import config from '../../../config/index.js';
/**
* Register the invitations path → subject mapping.
@@ -17,7 +18,22 @@ export function invitationSubjectRegistration({ registerPathSubject }) {
}
/**
- * Only platform admins can manage signup invitations.
+ * Platform admins keep full management of signup invitations. When
+ * `config.invitations.userFacing` is on (#3945, default OFF — preserves existing
+ * deployments' admin-only behavior), any other authenticated user can create their
+ * OWN invitation (a referral link) and read invitations THEY sent.
+ *
+ * Both grants below are TYPE-level (collection) CASL rules: no Invitation
+ * document-subject is registered (invitationSubjectRegistration only registers a
+ * path-subject), so a per-document `invitedBy` condition would never be evaluated —
+ * adding one here would be decorative and misleading. The REAL invitedBy-scoping for
+ * `read` is enforced downstream in InvitationsService.list() (#3833, deliberately
+ * mirrored there). This is also WHY no document-subject is registered: the
+ * `/invitations/:id/resend` route is a POST, which the generic HTTP-method→CASL-action
+ * map (methodToAction) resolves to the SAME 'create' action as new-invitation creation —
+ * granting 'create' at the type level would otherwise also open resend to any invitee.
+ * invitations.controller.js's `resend` explicitly stays admin-gated to close that gap;
+ * `remove` (revoke) is safe as-is because non-admins are never granted 'delete'.
* @param {Object} user
* @param {Object|null} membership
* @param {Object} builder
@@ -25,5 +41,12 @@ export function invitationSubjectRegistration({ registerPathSubject }) {
* @returns {void}
*/
export function invitationAbilities(user, membership, { can }) {
- if (Array.isArray(user?.roles) && user.roles.includes('admin')) can('manage', 'all');
+ if (Array.isArray(user?.roles) && user.roles.includes('admin')) {
+ can('manage', 'all');
+ return;
+ }
+ if (config.invitations?.userFacing) {
+ can('create', 'Invitation');
+ can('read', 'Invitation');
+ }
}
diff --git a/modules/invitations/routes/invitations.routes.js b/modules/invitations/routes/invitations.routes.js
index c0d757103..3ebaf7e62 100644
--- a/modules/invitations/routes/invitations.routes.js
+++ b/modules/invitations/routes/invitations.routes.js
@@ -19,6 +19,12 @@ import InvitationSchema from '../models/invitations.schema.js';
*/
export default (app) => {
const authLimiter = limiters.auth;
+ // #3945: create is no longer admin-only-by-trust once `invitations.userFacing`
+ // widens it — rate-limit it the same way verify/:token already is (mass-create
+ // is a DB-bloat / outbound-email-spam abuse surface). Passthrough no-op if the
+ // `invitationsCreate` profile is absent from config (mirrors every other named
+ // limiter in this stack).
+ const createLimiter = limiters.invitationsCreate;
// Public: report whether a token is a valid invite (+ prefill email).
app.route('/api/invitations/verify/:token').get(authLimiter, invitations.verify);
@@ -33,7 +39,7 @@ export default (app) => {
.route('/api/invitations')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
.get(invitations.list)
- .post(model.isValid(InvitationSchema.Invitation), invitations.create);
+ .post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create);
// Admin CRUD — revoke.
app
diff --git a/modules/invitations/services/invitations.service.js b/modules/invitations/services/invitations.service.js
index 5dc6e3510..014d54b52 100644
--- a/modules/invitations/services/invitations.service.js
+++ b/modules/invitations/services/invitations.service.js
@@ -12,6 +12,7 @@ import mails from '../../../lib/helpers/mailer/index.js';
import getBaseUrl from '../../../lib/helpers/getBaseUrl.js';
import logger from '../../../lib/services/logger.js';
import AppError from '../../../lib/helpers/AppError.js';
+import AnalyticsService from '../../../lib/services/analytics.js';
/**
* @desc Build the signup-invite email payload for an invitation. Shared by
@@ -250,7 +251,9 @@ const finalize = async (id, userId) => {
* via UserService.updateById (raw update — bypasses the client whitelist + Zod,
* so this is the ONLY way the field is ever written; never from a client body).
* invitations already depends on users (the E9 guard), so this keeps auth import-free.
- * 3. emits `invitation.accepted` so optional consumers (the billing #3842 credit-grant)
+ * 3. captures an `invitation_redeemed` analytics event (#3945, best-effort, never
+ * breaks accept),
+ * 4. emits `invitation.accepted` so optional consumers (the billing #3842 credit-grant)
* can react fire-and-forget.
*
* Referral substrate — NO credit-grant logic here; this only wires the field + event
@@ -299,6 +302,19 @@ const accept = async (invite, userId) => {
});
}
}
+ // Analytics — fire-and-forget, never break accept (#3945). Mirrors the try/catch
+ // convention already used around AnalyticsService calls elsewhere (auth.controller,
+ // billing.init) even though the service's own capture() never throws — belt+suspenders.
+ try {
+ AnalyticsService.capture({
+ distinctId: String(userId),
+ event: 'invitation_redeemed',
+ properties: {
+ invitationId: String(invite.id),
+ invitedBy: invitedBy ? String(invitedBy) : null,
+ },
+ });
+ } catch (_) { /* analytics must never break accept */ }
// Always emit on accept (invitedBy may be null) so the event is the single canonical
// "invite consumed" signal. Guard the emit so a synchronous listener throw cannot
// escape into the signup flow (an emitted 'error' would crash without the init listener).
diff --git a/modules/invitations/tests/invitations.controller.unit.tests.js b/modules/invitations/tests/invitations.controller.unit.tests.js
index 9162b23a3..bc749c5f6 100644
--- a/modules/invitations/tests/invitations.controller.unit.tests.js
+++ b/modules/invitations/tests/invitations.controller.unit.tests.js
@@ -79,21 +79,23 @@ describe('invitations.controller.remove', () => {
});
describe('invitations.controller.resend', () => {
+ const admin = { roles: ['admin'] };
+
test('resends via the service and responds success', async () => {
mockService.resend.mockResolvedValue({ id: 'i1', status: 'pending' });
- await controller.resend({ invitation: { id: 'i1' } }, makeRes());
+ await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes());
expect(mockService.resend).toHaveBeenCalledWith('i1');
expect(success).toHaveBeenCalledWith(expect.anything(), 'invitation resent');
expect(successInner).toHaveBeenCalledWith({ id: 'i1', status: 'pending' });
});
test('threads a 409 (non-pending) as Conflict', async () => {
mockService.resend.mockRejectedValue(Object.assign(new Error('Only pending invitations can be resent'), { status: 409 }));
- await controller.resend({ invitation: { id: 'i1' } }, makeRes());
+ await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes());
expect(error).toHaveBeenCalledWith(expect.anything(), 409, 'Conflict', expect.any(String));
});
test('maps a 422 (mailer unconfigured) as Unprocessable Entity', async () => {
mockService.resend.mockRejectedValue(Object.assign(new Error('mailer is not configured'), { status: 422 }));
- await controller.resend({ invitation: { id: 'i1' } }, makeRes());
+ await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes());
expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String));
});
// #3966 hardening: a bare transport rejection (no `.status`, unlike the
@@ -103,7 +105,7 @@ describe('invitations.controller.resend', () => {
test('mail-transport failure (no .status) responds with a generic message, never the raw provider error, and logs server-side', async () => {
const providerError = new Error('Resend API error: 401 Unauthorized — invalid API key sk_live_abc123');
mockService.resend.mockRejectedValue(providerError);
- await controller.resend({ invitation: { id: 'i1' } }, makeRes());
+ await controller.resend({ invitation: { id: 'i1' }, user: admin }, makeRes());
expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String));
const clientMessage = error.mock.calls[0][3];
@@ -116,6 +118,28 @@ describe('invitations.controller.resend', () => {
message: providerError.message,
}));
});
+
+ // #3945: resend stays admin-only even though invitationAbilities can widen 'create'
+ // to authenticated users — see invitations.policy.js / invitations.controller.js
+ // comments for why CASL alone cannot close this gap (POST→'create' method-mapping
+ // collision with new-invitation creation).
+ describe('#3945 admin-only gate (defense-in-depth, independent of CASL)', () => {
+ test('403s a non-admin caller and never calls the service', async () => {
+ await controller.resend({ invitation: { id: 'i1' }, user: { roles: ['user'] } }, makeRes());
+ expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', 'Only platform admins can resend invitations');
+ expect(mockService.resend).not.toHaveBeenCalled();
+ });
+ test('403s a caller with no roles array and never calls the service', async () => {
+ await controller.resend({ invitation: { id: 'i1' }, user: {} }, makeRes());
+ expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', expect.any(String));
+ expect(mockService.resend).not.toHaveBeenCalled();
+ });
+ test('403s when req.user is missing entirely and never calls the service', async () => {
+ await controller.resend({ invitation: { id: 'i1' } }, makeRes());
+ expect(error).toHaveBeenCalledWith(expect.anything(), 403, 'Forbidden', expect.any(String));
+ expect(mockService.resend).not.toHaveBeenCalled();
+ });
+ });
});
describe('invitations.controller.verify', () => {
diff --git a/modules/invitations/tests/invitations.policy.unit.tests.js b/modules/invitations/tests/invitations.policy.unit.tests.js
index 6f1b3ab4d..3700aa5af 100644
--- a/modules/invitations/tests/invitations.policy.unit.tests.js
+++ b/modules/invitations/tests/invitations.policy.unit.tests.js
@@ -1,7 +1,17 @@
import { jest } from '@jest/globals';
+// Mutable mock config object — invitationAbilities reads config.invitations?.userFacing
+// at CALL time (not import time), so tests toggle this same object reference between
+// flag states instead of re-mocking the module per test.
+const mockConfig = { invitations: { userFacing: false } };
+jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig }));
+
const { invitationSubjectRegistration, invitationAbilities } = await import('../policies/invitations.policy.js');
+beforeEach(() => {
+ mockConfig.invitations.userFacing = false;
+});
+
describe('invitationSubjectRegistration', () => {
test('registers a single path-subject predicate → Invitation', () => {
const registerPathSubject = jest.fn();
@@ -28,19 +38,60 @@ describe('invitationSubjectRegistration', () => {
});
describe('invitationAbilities', () => {
- test('grants manage all for admin', () => {
+ test('grants manage all for admin regardless of the userFacing flag (OFF)', () => {
+ const can = jest.fn();
+ invitationAbilities({ roles: ['admin'] }, null, { can });
+ expect(can).toHaveBeenCalledWith('manage', 'all');
+ expect(can).toHaveBeenCalledTimes(1);
+ });
+
+ test('grants manage all for admin regardless of the userFacing flag (ON)', () => {
+ mockConfig.invitations.userFacing = true;
const can = jest.fn();
invitationAbilities({ roles: ['admin'] }, null, { can });
expect(can).toHaveBeenCalledWith('manage', 'all');
+ // Admin returns early — never also gets the non-admin create/read grants.
+ expect(can).toHaveBeenCalledTimes(1);
});
- test('grants nothing for non-admin', () => {
+
+ test('userFacing OFF (default): grants nothing for a non-admin', () => {
const can = jest.fn();
invitationAbilities({ roles: ['user'] }, null, { can });
expect(can).not.toHaveBeenCalled();
});
- test('grants nothing when roles is absent', () => {
+
+ test('userFacing OFF (default): grants nothing when roles is absent', () => {
const can = jest.fn();
invitationAbilities({}, null, { can });
expect(can).not.toHaveBeenCalled();
});
+
+ test('userFacing ON (#3945): grants create + read (type-level) for a non-admin', () => {
+ mockConfig.invitations.userFacing = true;
+ const can = jest.fn();
+ invitationAbilities({ id: 'u1', roles: ['user'] }, null, { can });
+ expect(can).toHaveBeenCalledWith('create', 'Invitation');
+ expect(can).toHaveBeenCalledWith('read', 'Invitation');
+ expect(can).toHaveBeenCalledTimes(2);
+ });
+
+ test('userFacing ON: still grants create/read to a user-shaped caller with no roles array', () => {
+ mockConfig.invitations.userFacing = true;
+ const can = jest.fn();
+ invitationAbilities({ id: 'u1' }, null, { can });
+ // No roles array → not admin (falls through), but userFacing is ON so the
+ // non-admin grant still fires — any caller reaching this function is
+ // presumed authenticated by the policy middleware upstream.
+ expect(can).toHaveBeenCalledWith('create', 'Invitation');
+ expect(can).toHaveBeenCalledWith('read', 'Invitation');
+ });
+
+ test('userFacing ON does not grant delete/manage/update to a non-admin (resend/revoke stay out of scope)', () => {
+ mockConfig.invitations.userFacing = true;
+ const can = jest.fn();
+ invitationAbilities({ id: 'u1', roles: ['user'] }, null, { can });
+ expect(can).not.toHaveBeenCalledWith('delete', 'Invitation');
+ expect(can).not.toHaveBeenCalledWith('manage', expect.anything());
+ expect(can).not.toHaveBeenCalledWith('update', 'Invitation');
+ });
});
diff --git a/modules/invitations/tests/invitations.service.unit.tests.js b/modules/invitations/tests/invitations.service.unit.tests.js
index b04203e80..d9ac455bd 100644
--- a/modules/invitations/tests/invitations.service.unit.tests.js
+++ b/modules/invitations/tests/invitations.service.unit.tests.js
@@ -39,6 +39,11 @@ jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));
+const mockAnalytics = { capture: jest.fn() };
+jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
+ default: mockAnalytics,
+}));
+
const InvitationRepository = (await import('../repositories/invitations.repository.js')).default;
const InvitationService = (await import('../services/invitations.service.js')).default;
// Real events singleton — the service emits on it; we spy to assert the payload.
@@ -280,6 +285,42 @@ describe('InvitationService.accept (P8a — referral substrate seam)', () => {
expect(mockUserService.updateById).not.toHaveBeenCalled();
expect(emitSpy).not.toHaveBeenCalledWith('invitation.accepted', expect.anything());
});
+
+ // #3945: analytics — an accepted invite captures a redemption event.
+ test('captures an invitation_redeemed analytics event with invitationId + invitedBy', async () => {
+ const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' };
+ await InvitationService.accept(invite, 'u1');
+ expect(mockAnalytics.capture).toHaveBeenCalledWith({
+ distinctId: 'u1',
+ event: 'invitation_redeemed',
+ properties: { invitationId: 'i1', invitedBy: 'inviter1' },
+ });
+ });
+
+ test('invitedBy null (admin-created invite): invitation_redeemed still fires with invitedBy:null', async () => {
+ const invite = { id: 'i2', email: 'c@d.co', invitedBy: null };
+ await InvitationService.accept(invite, 'u2');
+ expect(mockAnalytics.capture).toHaveBeenCalledWith({
+ distinctId: 'u2',
+ event: 'invitation_redeemed',
+ properties: { invitationId: 'i2', invitedBy: null },
+ });
+ });
+
+ test('does NOT capture invitation_redeemed when finalize() returns null (no side-effects fire)', async () => {
+ InvitationRepository.finalize.mockResolvedValue(null);
+ const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' };
+ await InvitationService.accept(invite, 'u1');
+ expect(mockAnalytics.capture).not.toHaveBeenCalled();
+ });
+
+ test('best-effort: an analytics capture throw is swallowed (still emits, still returns the doc)', async () => {
+ mockAnalytics.capture.mockImplementationOnce(() => { throw new Error('analytics boom'); });
+ const invite = { id: 'i1', email: 'a@b.co', invitedBy: 'inviter1' };
+ const result = await InvitationService.accept(invite, 'u1');
+ expect(result).toMatchObject({ status: 'accepted' });
+ expect(emitSpy).toHaveBeenCalledWith('invitation.accepted', expect.objectContaining({ acceptedUserId: 'u1' }));
+ });
});
describe('InvitationService.sweepStaleClaims (E2)', () => {
diff --git a/modules/users/models/users.model.mongoose.js b/modules/users/models/users.model.mongoose.js
index 89184c812..5aaee807b 100644
--- a/modules/users/models/users.model.mongoose.js
+++ b/modules/users/models/users.model.mongoose.js
@@ -58,7 +58,8 @@ const UserMongoose = new Schema(
// client signup/update body. It is intentionally absent from the Zod schemas
// and from every users update whitelist, so a client cannot self-assign a
// referrer. null for self-serve signups and admin-created invites with no
- // inviter. No index yet (default:null, no referral-list query in P8a).
+ // inviter. Indexed (#3945) — the user-facing Referrals view queries "who did I
+ // refer" (`{ referredBy: }`).
referredBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
@@ -87,6 +88,9 @@ UserMongoose.index(
{ unique: true, name: 'email_ci_unique', collation: { locale: 'en', strength: 2 } },
);
+// #3945 — supports the user-facing Referrals view's "who did I refer" query.
+UserMongoose.index({ referredBy: 1 });
+
function addID() {
return this._id.toHexString();
}