Skip to content

feat(invitations): referral substrate — referredBy + invitation.accepted event (P8a)#3824

Merged
PierreBrisorgueil merged 3 commits into
masterfrom
feat/3814-referral-hook
Jun 10, 2026
Merged

feat(invitations): referral substrate — referredBy + invitation.accepted event (P8a)#3824
PierreBrisorgueil merged 3 commits into
masterfrom
feat/3814-referral-hook

Conversation

@PierreBrisorgueil

@PierreBrisorgueil PierreBrisorgueil commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Phase 8a — invitations ↔ org decouple (epic #3808)

The referral substrate for the credit offer (#5): schema + event only, zero credit-grant logic.

What lands

  • referredBy on the user (ObjectId, ref User, default null) — the inviter's id, set server-side on invite-accept. Server-set ONLY, never client-writable (verified at 3 layers): omitted from the Zod User/UserUpdate schemas (so the signup-POST model.isValid strip drops it from the body), absent from both users.update/updateAdmin whitelists (so removeSensitive strips it from PUT /api/users + admin update), written only via the raw UserService.updateById server path. (Deliberately NOT in Zod — adding it there would make it client-writable on signup; the server path bypasses Zod, so it isn't needed there.)
  • E22 shared finalize — the P3 eligibility seam's finalize closure now routes through InvitationsService.accept(invite, userId): finalizes the invite (P3 behavior) → sets user.referredBy = invite.invitedBy (best-effort, logged on failure — signup success is the invariant) → emits invitation.accepted. Fires on BOTH the token signup path AND the OAuth path (checkOAuthUserProfile) — OAuth-invited users are credited too. auth.controller.js still imports no invitation code.
  • invitation.accepted event { invitationId, email, invitedBy, acceptedUserId } (emit try/catch-wrapped against sync listener throws). billing.init.js wires a no-op listener (+ TODO(#5)).

E20 — referredBy blocked on all user-update paths

Verified absent from the update whitelists; negative tests assert a client-supplied referredBy is ignored on signup POST, PUT /api/users, and admin update (raw-doc reads).

Verification

  • Lint clean; 2110+ tests green (token-path + OAuth-path referredBy persistence, null-invitedBy handling, event payload + listener-throw crash-safety, billing no-op spy, 3 E20 negatives — all read the raw doc).
  • Independent spec-compliance review ✅ (the server-set-only security model verified airtight across all 4 client paths + the Zod-omission reasoning confirmed correct) + code-quality review (best-effort ordering sound; flagged 2 forward-looking adapting to front #5 notes — async-listener-throw + index — now captured as in-code TODO(#5)s).

Forward notes (for #5, not this PR)

  • The emit-site try/catch only catches synchronous listener throws — the future async credit-grant listener must self-guard its rejections (documented at the listener + in events.js).
  • referredBy index to be added with adapting to front #5's "who did I refer" query (so the index shape matches the access pattern).

Out of scope (filed separately)

A pre-existing hole surfaced during the E20 audit: email IS in the update whitelist and changing it does NOT reset emailVerified → a verified user can swap to an unverified email keeping emailVerified:true, which OAuth linkProviderByEmail trusts. Being filed as its own issue/ERRORS.md entry.

Closes #3814. Part of #3808.

Summary by CodeRabbit

  • New Features

    • User accounts now persistently record which existing user referred them when signing up through invitations.
    • Invitation acceptance events are now emitted when users complete invitation-based signup.
  • Security

    • Added server-side validation to ensure clients cannot set, modify, or override referral attribution data during signup and account profile updates.

…ted (#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.
…+ 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).
Copilot AI review requested due to automatic review settings June 10, 2026 19:57
@coderabbitai

coderabbitai Bot commented Jun 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PierreBrisorgueil, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 49 minutes and 21 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3c532a6e-8d2b-4cc7-90bf-64ab0c0cade8

📥 Commits

Reviewing files that changed from the base of the PR and between de19532 and fd5db61.

📒 Files selected for processing (4)
  • modules/billing/billing.init.js
  • modules/invitations/invitations.init.js
  • modules/invitations/services/invitations.service.js
  • modules/invitations/tests/invitations.service.unit.tests.js

Walkthrough

This PR implements a referral substrate for invitations: a server-only referredBy field tracks which user referred new sign-ups. When an invite is accepted (both token and OAuth flows), the InvitationsService.accept() method finalizes the invite, sets referredBy on the newly created user from the inviter's id, emits an invitation.accepted event, and registers a no-op listener in billing to exercise the cross-module seam. API endpoints are protected to prevent clients from writing the field directly.

Changes

Referral Substrate (referredBy + invitation.accepted)

Layer / File(s) Summary
User schema: referredBy field and constraints
modules/users/models/users.model.mongoose.js, modules/users/models/users.schema.js
referredBy is added as a nullable ObjectId reference to the Mongoose User model with server-side-only documentation. The Zod schema deliberately omits the field with an explanatory comment stating that it must not be client-writable via signup or user-update endpoints, and should be exposed via read-only projections only.
Event contract documentation
modules/invitations/lib/events.js
invitation.accepted event documentation is updated to specify that it is emitted by the new InvitationsService.accept method, clarify EventEmitter sync/async and try/catch behavior, and expand the payload contract to include invitedBy (nullable ObjectId), invitationId, email, and acceptedUserId.
InvitationsService.accept() method
modules/invitations/services/invitations.service.js
Introduces accept(invite, userId) that finalizes a claimed invite, conditionally updates the new user's referredBy field to invite.invitedBy when present (logging but not failing on update errors), emits invitation.accepted with guarded error handling to prevent listener failures from breaking signup, and exports the function in the module's public API.
Invitations eligibility wiring to accept()
modules/invitations/invitations.init.js
The finalize closure in the eligibility checker is updated to call InvitationsService.accept(invite, userId) instead of finalize(invite.id, userId). Documentation expands to clarify that accept is a superset of finalize, handles referral substrate wiring, and applies to both token-based and OAuth invite flows.
Billing module event listener registration
modules/billing/billing.init.js
Registers a no-op invitationEvents.on('invitation.accepted', …) listener in the billing initializer with placeholder TODOs for future referral-credit grant logic, exercising the cross-module event seam without changing current behavior.
Unit tests: service accept method and init wiring
modules/invitations/tests/invitations.service.unit.tests.js, modules/invitations/tests/invitations.init.unit.tests.js, modules/billing/tests/billing.init.unit.tests.js
Adds comprehensive unit test coverage for InvitationsService.accept() including finalize/burn behavior, conditional referredBy writes via UserService.updateById, event payload validation with null/missing invitedBy handling, best-effort swallowing of referred-by update failures, and resilience to synchronous listener exceptions. Init tests verify accept() is called instead of finalize(). Billing tests assert the listener is registered and invocable.
Integration tests: referral flow and event emission
modules/invitations/tests/invitations.integration.tests.js
Adds P8a referral substrate integration test suite validating that referredBy is set to the inviter's user id on both token-based and OAuth invite signup flows, that client-supplied referredBy in the request body is ignored, that invitation.accepted is emitted with expected payload fields, and that the billing listener is wired and callable. Tests include per-test config snapshot/restore and user cleanup.
API endpoint protection: prevent client referredBy writes
modules/users/tests/user.account.integration.tests.js, modules/users/tests/user.admin.integration.tests.js
Adds regression tests for both the logged-in PUT /api/users self-update endpoint and the admin user-update endpoint to verify that referredBy supplied in the request body is not persisted, while other whitelisted fields are updated normally. Uses direct database reads to confirm the field remains null/unset.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • pierreb-devkit/Node#3811: Proposes changes to the invitations claim/finalize/accept flow and InvitationsService behavior that directly align with the new accept() method and referral wiring introduced in this PR.
  • pierreb-devkit/Node#3808: Describes the same referral substrate implementation (referredBy field, invitation.accepted event, and server-side wiring) as the core of this PR.
  • pierreb-devkit/Node#3810: Covers the invitations ↔ billing event seam (invitationEvents and listener registration in billing.init) that is exercised by the no-op listener added in this PR.

Possibly related PRs

  • pierreb-devkit/Node#3820: Introduces the invitationEvents singleton in modules/invitations/lib/events.js; this PR builds on that foundation by updating the invitation.accepted contract and registering/emitting the event via the new InvitationsService.accept() and billing listener.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and clearly summarizes the main change: adding referral infrastructure (referredBy field and invitation.accepted event) as Phase 8a of the invitations epic, with specific scope noted (schema + event only, no credit logic).
Description check ✅ Passed The description is comprehensive and well-structured, covering What/Why, scope, validation, guardrails, and notes for reviewers. It clearly articulates the referral substrate implementation, security model, verification results, and forward notes.
Linked Issues check ✅ Passed All primary coding objectives from issue #3814 are met: referredBy added to User model and excluded from client-writable paths [#3814], shared InvitationsService.accept finalizes and emits invitation.accepted [#3814], billing listener is wired as no-op [#3814], and comprehensive tests verify token/OAuth paths plus E20 negatives [#3814].
Out of Scope Changes check ✅ Passed All changes are scoped to the referral substrate requirements: User model schema update, invitation event handling, finalize routing, billing listener, and verification tests. The pre-existing email/emailVerified hole is explicitly noted as filed separately.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/3814-referral-hook

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Jun 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.82%. Comparing base (609771f) to head (fd5db61).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3824      +/-   ##
==========================================
+ Coverage   91.80%   91.82%   +0.02%     
==========================================
  Files         159      159              
  Lines        5187     5201      +14     
  Branches     1652     1655       +3     
==========================================
+ Hits         4762     4776      +14     
  Misses        337      337              
  Partials       88       88              
Flag Coverage Δ
integration 59.50% <80.00%> (+0.05%) ⬆️
unit 72.48% <100.00%> (+0.07%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 609771f...fd5db61. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the backend “referral substrate” for the invitations decouple effort (epic #3808 / P8a): a server-only referredBy link on users plus an invitation.accepted event emitted when an invite is successfully consumed, with billing wired as a no-op listener for the future credit-grant feature (#5).

Changes:

  • Add referredBy to the User Mongoose model (server-set only; intentionally omitted from Zod write surface) and add negative integration coverage to ensure it cannot be client/admin-written.
  • Introduce InvitationsService.accept(invite, userId) as the shared “finalize + referral wiring + event emit” seam used by both token and OAuth invite-accept paths.
  • Document invitation.accepted event semantics and wire a no-op consumer in billing.init.js, with unit/integration tests validating the seam and payload.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
modules/users/tests/user.admin.integration.tests.js Adds an admin-path negative test proving referredBy is not persisted via admin user update.
modules/users/tests/user.account.integration.tests.js Adds self-update negative test proving referredBy is not client-writable via PUT /api/users.
modules/users/models/users.schema.js Documents intentional omission of referredBy from Zod write schemas to keep it server-only.
modules/users/models/users.model.mongoose.js Adds referredBy field to User schema (ObjectId ref User, default null).
modules/invitations/tests/invitations.service.unit.tests.js Adds unit coverage for InvitationsService.accept (finalize + referredBy stamping + event payload + best-effort behavior).
modules/invitations/tests/invitations.integration.tests.js Adds end-to-end tests verifying referredBy persistence for token + OAuth flows and event emission payload.
modules/invitations/tests/invitations.init.unit.tests.js Updates init unit tests to assert finalize closure routes through accept(invite, userId).
modules/invitations/services/invitations.service.js Implements accept() seam: finalize invite, best-effort stamp referredBy, emit invitation.accepted.
modules/invitations/lib/events.js Documents invitation.accepted payload and clarifies sync vs async listener error behavior.
modules/invitations/invitations.init.js Changes finalize closure to call InvitationsService.accept(invite, userId) instead of finalize(id, userId).
modules/billing/tests/billing.init.unit.tests.js Adds unit test asserting billing wires a no-op invitation.accepted listener.
modules/billing/billing.init.js Wires a no-op invitation.accepted listener (TODOs for #5).

Comment thread modules/invitations/services/invitations.service.js

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/billing/billing.init.js`:
- Around line 56-60: Add a JSDoc header for the new invitation.accepted listener
callback that documents the callback signature and return type: place a JSDoc
block immediately above the invitationEvents.on('invitation.accepted', (payload)
=> { ... }) listener describing `@param` {Object} payload (include common fields
used such as payload.invitedBy and note that it may be null) and `@returns`
{void}; keep the description concise and follow the project's JSDoc style for JS
files.

In `@modules/invitations/invitations.init.js`:
- Around line 90-93: The inline closure assigned to finalize should be extracted
into a named function with a JSDoc header so params/returns are explicit; create
a function like finalizeInvite(userId) that calls
InvitationsService.accept(invite, userId), add a JSDoc block documenting `@param`
{string|number} userId and `@returns` {Promise<...>} (matching
InvitationsService.accept), and replace the closure in the returned object
(finalize: finalizeInvite) to satisfy the JSDoc requirement.

In `@modules/invitations/services/invitations.service.js`:
- Around line 235-267: The current flow performs side-effects even when
finalize(invite.id, userId) returns null; change the logic in the accept flow to
only proceed with referral write (UserService.updateById) and the emit of
invitationEvents.emit('invitation.accepted', ...) when the finalize result is
truthy (non-null). Specifically, after calling finalize(...) check the returned
result and return early if it's null; only then attempt the referredBy update
and wrap the emit in the existing try/catch so no side-effects occur for
non-finalized invites, keeping existing logger.warn handling for failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fd4e1d3b-c74e-406c-9810-d184371e7614

📥 Commits

Reviewing files that changed from the base of the PR and between 609771f and de19532.

📒 Files selected for processing (12)
  • modules/billing/billing.init.js
  • modules/billing/tests/billing.init.unit.tests.js
  • modules/invitations/invitations.init.js
  • modules/invitations/lib/events.js
  • modules/invitations/services/invitations.service.js
  • modules/invitations/tests/invitations.init.unit.tests.js
  • modules/invitations/tests/invitations.integration.tests.js
  • modules/invitations/tests/invitations.service.unit.tests.js
  • modules/users/models/users.model.mongoose.js
  • modules/users/models/users.schema.js
  • modules/users/tests/user.account.integration.tests.js
  • modules/users/tests/user.admin.integration.tests.js

Comment thread modules/billing/billing.init.js
Comment thread modules/invitations/invitations.init.js
Comment thread modules/invitations/services/invitations.service.js
…+ 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.
@PierreBrisorgueil PierreBrisorgueil merged commit 1aacb58 into master Jun 10, 2026
8 checks passed
@PierreBrisorgueil PierreBrisorgueil deleted the feat/3814-referral-hook branch June 10, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Referral hook backend — referredBy + invitation.accepted (Node, P8a)

2 participants