|
| 1 | +# Invitations module |
| 2 | + |
| 3 | +Optional, standalone module owning the **platform invitation** concept: invite a contact by |
| 4 | +email → single-use token + `invitedBy` + beta-gate eligibility. Depends only on `auth` |
| 5 | +(via the `registerSignupEligibility` hook — `auth` never imports this module). Knows nothing |
| 6 | +about organizations: getting an invited person into an org is the 2-step flow |
| 7 | +*platform invite → `org.addMember(userId)`*. |
| 8 | + |
| 9 | +- Routes: `/api/invitations` (admin list/create + revoke via `DELETE /:invitationId` — no |
| 10 | + update endpoint) + `/api/invitations/verify/:token` (public). |
| 11 | +- Model `Invitation` → collection `invitations` (`email`, `token`, `invitedBy`, `status`, |
| 12 | + `expiresAt`, `consumingAt`, `acceptedAt`, `acceptedUserId`, `revokedAt`, `usedAt`). |
| 13 | +- Signup gate: two-phase claim/finalize (`consumingAt` CAS + lazy 15-min stale sweep), |
| 14 | + email pin, soft revoke. See `services/invitations.service.js`. |
| 15 | + |
| 16 | +## The referral substrate (what "Referral rewards — coming soon" hooks into) |
| 17 | + |
| 18 | +The reward **seam** ships; the reward **logic** is deliberately deferred (#5, gates in #3833). |
| 19 | +Two primitives are written on every accepted invite — on **both** the token-signup path and |
| 20 | +the OAuth path (shared `accept(invite, userId)`): |
| 21 | + |
| 22 | +1. **`user.referredBy`** — the inviter's userId, stamped server-side on the created account |
| 23 | + (never client-writable: absent from the Zod schemas + update whitelists; written via the |
| 24 | + raw repository path only). This is the durable referral edge — it supports |
| 25 | + *compute-on-read* forever, even if an event was missed. |
| 26 | +2. **`invitation.accepted` event** — emitted by this module's singleton |
| 27 | + (`lib/events.js`): |
| 28 | + |
| 29 | + ```js |
| 30 | + invitationEvents.emit('invitation.accepted', { |
| 31 | + invitationId, // ← natural IDEMPOTENCY KEY for any grant |
| 32 | + email, // invitee email (lowercased) |
| 33 | + invitedBy, // inviter userId — the admin API always stamps the creating admin; |
| 34 | + // null only for actor-less inserts (legacy/scripted data) |
| 35 | + acceptedUserId, // the REFEREE — double-sided rewards need no schema change |
| 36 | + }); |
| 37 | + ``` |
| 38 | + |
| 39 | +Both the **referrer** (`invitedBy`) and the **referee** (`acceptedUserId`) are identifiable |
| 40 | +from the payload — reward either side, or both. |
| 41 | + |
| 42 | +## Implementing rewards — two architectures (pick per product) |
| 43 | + |
| 44 | +> **⚠️ Downstream rule first:** stack files (`modules/billing/**`, this module, `lib/`) |
| 45 | +> stay **byte-identical** downstream — the drift gate blocks edits and `/update-stack` |
| 46 | +> would clobber them. A downstream project therefore NEVER wires a listener by editing |
| 47 | +> `billing.init.js`. The two sanctioned channels are: **config** (deep-merged |
| 48 | +> `{project}.config.js` — for the standard reward below) and **project-only modules** |
| 49 | +> (glob-discovered, e.g. `modules/trawl-rewards/` — for custom logic). |
| 50 | +
|
| 51 | +### A. Standard grant — ships IN the stack, downstream enables it by CONFIG |
| 52 | + |
| 53 | +The grant listener is implemented **once, upstream, in `billing.init.js`** (#5 — the |
| 54 | +no-op seam with the `TODO(#5)` is already there), entirely gated by config: |
| 55 | + |
| 56 | +```js |
| 57 | +// modules/billing/config/billing.development.config.js — stack default: OFF |
| 58 | +// (this block does NOT exist yet — #5 adds it together with the listener impl) |
| 59 | +billing: { referral: { enabled: false, referrerUnits: 0, refereeUnits: 0 } } |
| 60 | +``` |
| 61 | + |
| 62 | +```js |
| 63 | +// a downstream's config/defaults/{project}.config.js — the ONLY thing it touches: |
| 64 | +billing: { referral: { enabled: true, referrerUnits: 500, refereeUnits: 200 } } |
| 65 | +``` |
| 66 | + |
| 67 | +The stack listener (#5 implementation sketch — lives upstream, never downstream): |
| 68 | + |
| 69 | +```js |
| 70 | +invitationEvents.on('invitation.accepted', async ({ invitationId, invitedBy, acceptedUserId }) => { |
| 71 | + try { |
| 72 | + const cfg = config.billing?.referral; |
| 73 | + if (!cfg?.enabled) return; // downstream flips this |
| 74 | + if (invitedBy && cfg.referrerUnits) await grantCredits({ userId: invitedBy, units: cfg.referrerUnits, key: `referral:${invitationId}:referrer` }); |
| 75 | + if (cfg.refereeUnits) await grantCredits({ userId: acceptedUserId, units: cfg.refereeUnits, key: `referral:${invitationId}:referee` }); |
| 76 | + } catch (err) { |
| 77 | + // ⚠️ MANDATORY self-guard: EventEmitter.emit is synchronous — the emit-site |
| 78 | + // try/catch in invitations.service only catches SYNC throws. An async |
| 79 | + // listener's rejection escapes as an unhandledRejection. Never let it. |
| 80 | + logger.error('[billing] referral grant failed', { err: err?.message, stack: err?.stack }); |
| 81 | + } |
| 82 | +}); |
| 83 | +``` |
| 84 | +
|
| 85 | +Rules that make this production-grade: |
| 86 | +
|
| 87 | +- **Idempotency** — key every grant on `invitationId` (unique index on the ledger `key`): |
| 88 | + a replayed/duplicate event can never double-credit. |
| 89 | +- **Reconcile cron (safety net)** — EventEmitter is in-process fire-and-forget; a crash |
| 90 | + between accept and grant loses the event. Pair the listener with a periodic script |
| 91 | + (k8s CronJob, pattern: `modules/billing/crons/`) that scans |
| 92 | + ALL `invitations { status:'accepted' }` vs the grant ledger keys and back-fills misses |
| 93 | + (scan all accepted, not just `invitedBy:{$ne:null}` — referee grants exist even when |
| 94 | + `invitedBy` is null, so a referrer-only scan would miss referee-only back-fills). |
| 95 | + The listener is latency; the cron is truth. |
| 96 | +
|
| 97 | +### A'. Custom rewards (cashback, Stripe credit note, partner webhook) — project-only module |
| 98 | +
|
| 99 | +When a downstream needs logic beyond units (cashback %, coupons, external payouts), it |
| 100 | +ships its OWN module — glob discovery means zero stack edits: |
| 101 | +
|
| 102 | +```js |
| 103 | +// modules/{project}-rewards/{project}-rewards.init.js (downstream-only module) |
| 104 | +import invitationEvents from '../invitations/lib/events.js'; |
| 105 | + |
| 106 | +export default async () => { |
| 107 | + invitationEvents.on('invitation.accepted', async (payload) => { |
| 108 | + try { await myCashbackFlow(payload); } // same idempotency key rule |
| 109 | + catch (err) { logger.error('[rewards] cashback failed', { err: err?.message, stack: err?.stack }); } |
| 110 | + }); |
| 111 | +}; |
| 112 | +``` |
| 113 | +
|
| 114 | +Multiple listeners on the shared emitter are fine (the stack's standard grant + a |
| 115 | +project's custom one can coexist); each owns its own failure handling. |
| 116 | +
|
| 117 | +### B. Compute-on-read (no writes, simplest) |
| 118 | +
|
| 119 | +Derive the reward at quota/entitlement time instead of granting: |
| 120 | +
|
| 121 | +```js |
| 122 | +// illustrative — a countAccepted helper does not exist yet; #5 adds it (or an equivalent query) |
| 123 | +const accepted = await InvitationRepository.countAccepted({ invitedBy: userId }); |
| 124 | +const bonus = accepted * config.billing.referral.referrerUnits; |
| 125 | +``` |
| 126 | +
|
| 127 | +Always consistent (survives missed events), zero ledger. Costs a query on the hot |
| 128 | +entitlement path (index `invitations.invitedBy` first — the field this query hits; |
| 129 | +`users.referredBy` gets its own index only if referral lists query it), |
| 130 | +and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts |
| 131 | +(e.g. "+1 project slot per referral"), wrong for money-shaped balances. |
| 132 | +
|
| 133 | +> Recommended: **A + reconcile cron** for credit/cashback economies; **B** for static |
| 134 | +> entitlement boosts. The substrate supports both simultaneously. |
| 135 | +
|
| 136 | +## Gates before shipping rewards (#3833 — read it first) |
| 137 | +
|
| 138 | +1. **Scope the list**: `GET /api/invitations` is platform-global today (admin-only by |
| 139 | + CASL). Before widening `create Invitation` to regular users, add an `invitedBy`-scoped |
| 140 | + `/mine` (PII: invitee emails). |
| 141 | +2. **Self-referral guard** — alternate-email self-invites become valuable once credits exist. |
| 142 | +3. **Open-signup hole** — claim/finalize are gated on `!config.sign.up`: with public signup |
| 143 | + open the event never fires. Resolve (finalize-without-claim) or hide the Referrals tab |
| 144 | + before any open deployment enables rewards. |
| 145 | +4. **Index `referredBy`** alongside the first real referral query. |
| 146 | +
|
| 147 | +## UI |
| 148 | +
|
| 149 | +The Vue module (`src/modules/invitations/` in Devkit Vue) ships the admin beta-gate tab and |
| 150 | +the account **Referrals** tab (invite a contact, my invites + status chips, a referral |
| 151 | +summary, and the "Referral rewards — coming soon" placeholder where #5's balance lands — |
| 152 | +the placeholder is contractually digit-free until real numbers exist). |
0 commit comments