You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
* 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.
0 commit comments