From 1f71a5f6ab41b83b75c1c577cd82789695084ded Mon Sep 17 00:00:00 2001 From: "onFire(Abhi)" <40654066+AbhiShake1@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:35:52 +0530 Subject: [PATCH] feat: add invite system with payment-gated reward and abuse guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a referral invite system on top of LinkForty's existing deferred deep linking infrastructure. The system is designed for invite-based growth where the reward is gated on the invitee making a payment (not just signing up), making fake-account farming economically irrational. Three guards enforce the abuse-prevention policy at the payment event: 1. Self-invite: inviterId === inviteeUserId → reject (deterministic) 2. Already-on-platform: inviteeCreatedAt < invite.createdAt → reject 3. Single consumption: WHERE status='pending' in UPDATE prevents double-reward even under concurrent requests (DB-level atomicity) New endpoints: - POST /api/invites — create a pending invite - POST /api/invites/:id/consume — consume on payment (runs all guards) - POST /api/invites/:id/expire — manually expire (no reward) - GET /api/invites/:inviterId — list invites for an inviter - GET /api/invites/id/:id — get single invite On successful consumption, an `invite_consumed_event` webhook fires to the inviter's registered webhook endpoints, carrying the payment amount, reward amount, and both user identities. 37 new tests (22 unit + 15 integration) cover every guard, guard ordering, race conditions, boundary cases, and the full create→consume→verify lifecycle. Full suite: 176 tests passing. Deductive proof of correctness in docs/invite-system-proof.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/invite-system-proof.md | 296 +++++++++++++++++++++ src/index.ts | 5 +- src/lib/database.ts | 32 +++ src/lib/invite.test.ts | 239 +++++++++++++++++ src/lib/invite.ts | 189 ++++++++++++++ src/routes/index.ts | 1 + src/routes/invites.test.ts | 506 ++++++++++++++++++++++++++++++++++++ src/routes/invites.ts | 234 +++++++++++++++++ src/types/index.ts | 75 +++++- 9 files changed, 1575 insertions(+), 2 deletions(-) create mode 100644 docs/invite-system-proof.md create mode 100644 src/lib/invite.test.ts create mode 100644 src/lib/invite.ts create mode 100644 src/routes/invites.test.ts create mode 100644 src/routes/invites.ts diff --git a/docs/invite-system-proof.md b/docs/invite-system-proof.md new file mode 100644 index 0000000..b70b735 --- /dev/null +++ b/docs/invite-system-proof.md @@ -0,0 +1,296 @@ +# Invite System — Deductive Proof of Correctness + +## Overview + +This document proves, by deduction from the source code and test evidence, +that the invite system built on top of LinkForty satisfies the following +properties under the user's specified model: + +1. **Self-invite is forbidden** — a user cannot earn a reward by inviting themselves. +2. **Already-on-platform users don't count** — if the invitee already had an account before the invite was created, no reward is issued. +3. **Invitation expires once consumed** — after the invitee pays and the reward is issued, the invite cannot be consumed again (no double-reward, no device-reinstall farming). +4. **The gate is payment, not signup** — the reward only fires when the invitee makes a payment, making fake-account farming economically irrational. +5. **Reusable links** — a link can be clicked N times by the same user before they decide; no click cap. + +## System Model + +### Entities + +- **Inviter** (`inviter_id`): the user who creates and shares the invite link. Their identity is baked into the invite at creation time. +- **Invitee** (`invitee_user_id`): the user who clicks the link, installs the app, signs up, and eventually pays. +- **Invite**: a record in the `invites` table with status `pending` → `consumed` or `expired`. +- **Payment**: an external event (from the caller's payment system) that triggers the consume endpoint. + +### Flow + +``` +1. Inviter creates invite → POST /api/invites { inviterId, inviterName } + → invite.status = "pending" +2. Invitee clicks link → LinkForty redirect (existing functionality) +3. Invitee installs app → POST /api/sdk/v1/install (existing, deferred DL) +4. Invitee signs up → (nothing happens — no reward at signup) +5. Invitee pays → POST /api/invites/:id/consume { inviteeUserId, inviteeCreatedAt, paymentAmount, ... } + → guards run → reward issued or rejected +6. Reward webhook fires → invite_consumed_event to inviter's webhook +``` + +## Invariants and Proofs + +### Invariant 1: Self-invite is impossible + +**Claim:** If `inviterId === inviteeUserId`, the consume endpoint returns 403 with `rejectionReason: "self_invite"` and no reward is issued. + +**Proof (by code inspection):** + +The `consumeInvite` function in `src/lib/invite.ts` executes guards in order: + +``` +Guard 3 (status): isConsumable(invite.status) → if false, reject "not_pending" +Guard 1 (self): isSelfInvite(inviterId, inviteeId) → if true, reject "self_invite" +Guard 2 (platform): isAlreadyOnPlatform(...) → if true, reject "already_on_platform" +``` + +The `isSelfInvite` function is: + +```typescript +export function isSelfInvite(inviterId: string, inviteeUserId: string): boolean { + return inviterId === inviteeUserId; +} +``` + +This is a strict string equality check. The `inviterId` is read from the +`invites` table (set at creation time by the inviter), and `inviteeUserId` +comes from the consume request body (the paying user's identity from the +caller's auth system). + +**Deduction:** If the inviter created the invite with their own `inviterId`, +and they later attempt to consume it with the same `userId`, then +`inviterId === inviteeUserId` is true by construction, and the guard rejects. +The attacker cannot alter `inviterId` (it's persisted in the DB) and cannot +make their own `userId` differ from itself. + +**Test evidence:** +- `invite.test.ts > isSelfInvite > returns true when inviterId equals inviteeUserId` ✓ +- `invite.test.ts > consumeInvite > rejects with self_invite when inviterId equals inviteeUserId` ✓ +- `invites.test.ts > returns 403 with self_invite when inviter === invitee` ✓ + +**QED.** + +--- + +### Invariant 2: Already-on-platform users are rejected + +**Claim:** If `inviteeCreatedAt < invite.createdAt`, the consume endpoint returns 403 with `rejectionReason: "already_on_platform"` and no reward is issued. + +**Proof (by code inspection):** + +The `isAlreadyOnPlatform` function is: + +```typescript +export function isAlreadyOnPlatform( + inviteeCreatedAt: string | Date, + inviteCreatedAt: string | Date +): boolean { + const inviteeTime = new Date(inviteeCreatedAt).getTime(); + const inviteTime = new Date(inviteCreatedAt).getTime(); + return inviteeTime < inviteTime; +} +``` + +This compares two timestamps. `inviteeCreatedAt` is provided by the caller +(the invitee's account creation time from their auth system), and +`invite.createdAt` is the timestamp when the invite was created (from the +`invites` table, set by PostgreSQL's `DEFAULT NOW()`). + +**Deduction:** If the invitee's account existed before the invite was +created, then `inviteeTime < inviteTime` is true, and the guard rejects. +The invite is meant to acquire NEW users — a user who already existed +cannot be "acquired" by this invite. + +**Boundary case:** If timestamps are exactly equal (`inviteeTime === inviteTime`), +the function returns `false` (allowed). This is correct: a user who signed +up at the exact moment the invite was created is ambiguous, and the payment +gate already prevents farming (the invitee must pay to trigger the reward). + +**Test evidence:** +- `invite.test.ts > isAlreadyOnPlatform > returns true when invitee account was created BEFORE the invite` ✓ +- `invite.test.ts > isAlreadyOnPlatform > returns false when invitee account was created AFTER the invite` ✓ +- `invite.test.ts > isAlreadyOnPlatform > returns false when timestamps are exactly equal (boundary: allow)` ✓ +- `invite.test.ts > isAlreadyOnPlatform > returns true when invitee is 1ms before invite` ✓ +- `invite.test.ts > consumeInvite > rejects with already_on_platform when invitee existed before invite` ✓ +- `invites.test.ts > returns 403 with already_on_platform when invitee existed before invite` ✓ + +**QED.** + +--- + +### Invariant 3: An invite can only be consumed once (no double-reward) + +**Claim:** After an invite transitions to `consumed`, all subsequent consume attempts return 403 with `rejectionReason: "not_pending"` and no reward is issued. + +**Proof (by code inspection + database constraint):** + +This invariant is enforced at TWO layers: + +**Layer 1 — Application guard (`isConsumable`):** + +```typescript +export function isConsumable(status: InviteStatus): boolean { + return status === 'pending'; +} +``` + +When `consumeInvite` is called, it reads the invite's current status from +the database. If `status !== 'pending'`, it rejects immediately with +`not_pending` before any other guard runs. + +**Layer 2 — Database atomicity (race condition protection):** + +The consume UPDATE includes a `WHERE status = 'pending'` clause: + +```sql +UPDATE invites +SET status = 'consumed', ... +WHERE id = $6 AND status = 'pending' +RETURNING * +``` + +If two concurrent requests pass the application-layer read (both see +`pending`), only one UPDATE will succeed (PostgreSQL row-level locking +ensures the first UPDATE transitions the row to `consumed`, and the second +UPDATE's `WHERE status = 'pending'` no longer matches). The second request +gets 0 rows returned and is rejected with `not_pending` + "concurrent" +detail. + +**Deduction:** Once `status = 'consumed'`, no subsequent request — whether +sequential or concurrent — can transition the invite again. The reward is +issued exactly once. + +**Test evidence:** +- `invite.test.ts > isConsumable > returns true for pending, false for consumed, false for expired` ✓ +- `invite.test.ts > consumeInvite > rejects with not_pending when invite is already consumed` ✓ +- `invite.test.ts > consumeInvite > rejects on race condition: concurrent consume between read and write` ✓ +- `invite.test.ts > consumeInvite > guard ordering: status check happens before self-invite check` ✓ +- `invites.test.ts > returns 403 with not_pending when invite is already consumed` ✓ +- `invites.test.ts > Full invite lifecycle > second consume attempt is rejected (not_pending)` ✓ + +**QED.** + +--- + +### Invariant 4: The reward gate is payment, not signup + +**Claim:** No reward is issued at signup. The reward only fires when the consume endpoint is called with a positive `paymentAmount`. + +**Proof (by code inspection):** + +There is no signup endpoint in the invite system. The only endpoint that +transitions an invite to `consumed` and sets `reward_issued = true` is: + +``` +POST /api/invites/:id/consume +``` + +This endpoint requires: +- `paymentAmount: z.number().positive()` — must be a positive number (Zod validation) +- `paymentCurrency: z.string().length(3)` — must be a 3-letter currency code + +If `paymentAmount` is missing, zero, or negative, Zod rejects with 400 +before any guard runs. The consume function is never called. + +**Deduction:** The reward cannot fire without a payment. An attacker who +creates a fake account and signs up triggers nothing. To trigger the +reward, they must make a real payment — which costs them money, defeating +the purpose of farming invite rewards. + +**Test evidence:** +- `invites.test.ts > rejects with 400 when paymentAmount is missing or non-positive` ✓ +- `invites.test.ts > succeeds with 200 when all guards pass and fires invite_consumed_event webhook` ✓ (reward only fires after valid payment) + +**QED.** + +--- + +### Invariant 5: Links are reusable (no click cap) + +**Claim:** A user can click an invite link multiple times without being blocked. + +**Proof (by absence of code):** + +The invite system does not interact with the click tracking system. Click +events are logged by the existing `redirect.ts` route, which has no +per-link click limit. The invite system only acts at the consume (payment) +endpoint. There is no `max_clicks` field on the `invites` table, no +click-count check in any guard, and no rate limiting on the redirect route +per-link. + +**Deduction:** Clicks are free. The system only cares about the payment +event, not how many times the link was clicked before the payment. + +**QED.** + +--- + +## Guard Ordering Proof + +**Claim:** Guards are evaluated in order: status → self-invite → already-on-platform, and the first failing guard determines the rejection reason. + +**Proof (by code inspection):** + +In `consumeInvite` (`src/lib/invite.ts`): + +``` +1. Fetch invite from DB +2. if (!isConsumable(status)) → reject "not_pending" +3. if (isSelfInvite(...)) → reject "self_invite" +4. if (isAlreadyOnPlatform(...)) → reject "already_on_platform" +5. All passed → UPDATE to consumed +``` + +The function uses early `return` for each rejection, so only the first +failing guard's rejection reason is returned. + +**Test evidence:** +- `invite.test.ts > guard ordering: status check happens before self-invite check` ✓ + (consumed + self-invite → rejected as `not_pending`, not `self_invite`) +- `invite.test.ts > guard ordering: self-invite check happens before already-on-platform check` ✓ + (self-invite + already-on-platform → rejected as `self_invite`, not `already_on_platform`) + +**QED.** + +--- + +## Summary Table + +| Property | Guard | Layer | Test Count | Status | +|----------|-------|-------|------------|--------| +| Self-invite forbidden | `isSelfInvite` | Application | 4 | ✅ Proven | +| Already-on-platform rejected | `isAlreadyOnPlatform` | Application | 6 | ✅ Proven | +| Single consumption (no double-reward) | `isConsumable` + `WHERE status='pending'` | Application + DB | 6 | ✅ Proven | +| Payment gate (not signup) | Zod `paymentAmount.positive()` | Validation | 2 | ✅ Proven | +| Reusable links (no click cap) | Absence of click-limit code | — | — | ✅ Proven | +| Guard ordering | Early return in `consumeInvite` | Application | 2 | ✅ Proven | + +**Total: 37 tests, all passing. 176 tests in full suite (including 139 pre-existing), all passing.** + +## What This System Does NOT Protect Against (by design) + +Per the user's explicit requirements, the following are NOT guarded: + +1. **Fake accounts with different emails** — A user could create a second + account with a different email and invite themselves. This is NOT + blocked because the reward requires a **payment**, not just a signup. + The attacker would have to pay real money to trigger the reward, which + costs more than the reward is worth. + +2. **Device reinstall farming** — Same device reinstalling the app and + paying again would trigger a new invite. This is NOT blocked because + the invite is marked `consumed` after the first payment — the same + invite cannot be consumed twice. A NEW invite would need to be created, + which requires the inviter to actively share a new link. + +3. **Fingerprint spoofing** — The probabilistic fingerprint match (70% + threshold) is gameable by a determined attacker. This is NOT blocked + because the reward is gated on payment, not on attribution accuracy. + The fingerprint only determines WHICH invite gets credited, not WHETHER + a reward is issued. diff --git a/src/index.ts b/src/index.ts index f656902..567f7d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { webhookRoutes } from './routes/webhooks.js'; import { templateRoutes } from './routes/templates.js'; import { qrRoutes } from './routes/qr.js'; import { wellKnownRoutes } from './routes/well-known.js'; +import { inviteRoutes } from './routes/invites.js'; /** * Configuration options for creating a LinkForty server instance. @@ -66,6 +67,7 @@ export async function createServer(options: ServerOptions = {}) { await fastify.register(webhookRoutes); await fastify.register(templateRoutes); await fastify.register(qrRoutes); + await fastify.register(inviteRoutes); return fastify; } @@ -77,5 +79,6 @@ export * from './lib/database.js'; export * from './lib/fingerprint.js'; export * from './lib/webhook.js'; export * from './lib/event-emitter.js'; +export * from './lib/invite.js'; export * from './types/index.js'; -export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes } from './routes/index.js'; +export { redirectRoutes, linkRoutes, analyticsRoutes, sdkRoutes, webhookRoutes, templateRoutes, qrRoutes, previewRoutes, debugRoutes, wellKnownRoutes, inviteRoutes } from './routes/index.js'; diff --git a/src/lib/database.ts b/src/lib/database.ts index d4c0848..1f8fe36 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -524,6 +524,38 @@ export async function initializeDatabase(options: DatabaseOptions = {}) { END $$; `); + // Invites table — tracks referral invitations with payment-gated reward + // issuance. An invite is created when a user shares a link, and is + // consumed only when the invitee makes a payment (not on signup). The + // three guards (self-invite, already-on-platform, status) are enforced + // in the consume endpoint, not at the DB level, because they require + // application-level identity comparison. + await client.query(` + CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inviter_id VARCHAR(255) NOT NULL, + inviter_name VARCHAR(255), + link_id UUID REFERENCES links(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + invitee_user_id VARCHAR(255), + invitee_email VARCHAR(255), + consumed_at TIMESTAMP, + payment_amount DECIMAL(10, 2), + payment_currency VARCHAR(3), + reward_issued BOOLEAN DEFAULT false, + reward_amount DECIMAL(10, 2), + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + + // Indexes for invites + await client.query('CREATE INDEX IF NOT EXISTS idx_invites_inviter_id ON invites(inviter_id)'); + await client.query('CREATE INDEX IF NOT EXISTS idx_invites_status ON invites(status) WHERE status = \'pending\''); + await client.query('CREATE INDEX IF NOT EXISTS idx_invites_link_id ON invites(link_id)'); + await client.query('CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invites(created_at DESC)'); + console.log('Database schema initialized successfully'); } catch (error) { console.error('Error initializing database:', error); diff --git a/src/lib/invite.test.ts b/src/lib/invite.test.ts new file mode 100644 index 0000000..1b0c3dc --- /dev/null +++ b/src/lib/invite.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; + +// Mock the database module so tests don't require a real Postgres connection. +vi.mock('./database', () => ({ + db: { + query: vi.fn(), + }, +})); + +import { isSelfInvite, isAlreadyOnPlatform, isConsumable, consumeInvite } from './invite'; +import { db } from './database'; + +const mockDbQuery = db.query as Mock; + +const INVITE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const INVITER_ID = 'user-surya'; +const INVITEE_ID = 'user-bob'; +const INVITE_CREATED_AT = '2026-07-01T10:00:00.000Z'; + +describe('isSelfInvite', () => { + it('returns true when inviterId equals inviteeUserId', () => { + expect(isSelfInvite('user-1', 'user-1')).toBe(true); + }); + + it('returns false when inviterId differs from inviteeUserId', () => { + expect(isSelfInvite('user-1', 'user-2')).toBe(false); + }); + + it('is case-sensitive (different case = different user)', () => { + expect(isSelfInvite('User-1', 'user-1')).toBe(false); + }); + + it('rejects empty string self-invite (both empty = same identity)', () => { + expect(isSelfInvite('', '')).toBe(true); + }); +}); + +describe('isAlreadyOnPlatform', () => { + it('returns true when invitee account was created BEFORE the invite', () => { + // Invitee signed up on June 1, invite created on July 1 → already on platform + expect(isAlreadyOnPlatform('2026-06-01T10:00:00.000Z', '2026-07-01T10:00:00.000Z')).toBe(true); + }); + + it('returns false when invitee account was created AFTER the invite', () => { + // Invitee signed up on August 1, invite created on July 1 → new user + expect(isAlreadyOnPlatform('2026-08-01T10:00:00.000Z', '2026-07-01T10:00:00.000Z')).toBe(false); + }); + + it('returns false when timestamps are exactly equal (boundary: allow)', () => { + expect(isAlreadyOnPlatform('2026-07-01T10:00:00.000Z', '2026-07-01T10:00:00.000Z')).toBe(false); + }); + + it('returns false when invitee is 1ms after invite (new user)', () => { + expect(isAlreadyOnPlatform('2026-07-01T10:00:00.001Z', '2026-07-01T10:00:00.000Z')).toBe(false); + }); + + it('returns true when invitee is 1ms before invite (already on platform)', () => { + expect(isAlreadyOnPlatform('2026-07-01T09:59:59.999Z', '2026-07-01T10:00:00.000Z')).toBe(true); + }); + + it('accepts Date objects as well as ISO strings', () => { + expect(isAlreadyOnPlatform(new Date('2026-06-01'), new Date('2026-07-01'))).toBe(true); + }); +}); + +describe('isConsumable', () => { + it('returns true for pending status', () => { + expect(isConsumable('pending')).toBe(true); + }); + + it('returns false for consumed status', () => { + expect(isConsumable('consumed')).toBe(false); + }); + + it('returns false for expired status', () => { + expect(isConsumable('expired')).toBe(false); + }); +}); + +describe('consumeInvite — guard integration', () => { + beforeEach(() => { + mockDbQuery.mockReset(); + }); + + it('rejects with not_found when invite does not exist', async () => { + mockDbQuery.mockResolvedValueOnce({ rows: [] }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('not_found'); + expect(result.rewardIssued).toBe(false); + }); + + it('rejects with not_pending when invite is already consumed', async () => { + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('not_pending'); + expect(result.status).toBe('consumed'); + }); + + it('rejects with not_pending when invite is expired', async () => { + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'expired', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('not_pending'); + expect(result.status).toBe('expired'); + }); + + it('rejects with self_invite when inviterId equals inviteeUserId', async () => { + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITER_ID, 'surya@test.com', // invitee = inviter + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('self_invite'); + expect(result.rewardIssued).toBe(false); + }); + + it('rejects with already_on_platform when invitee existed before invite', async () => { + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-06-01T10:00:00.000Z', // before invite (July 1) + 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('already_on_platform'); + expect(result.rewardIssued).toBe(false); + }); + + it('succeeds and issues reward when all guards pass', async () => { + // 1. Fetch invite (pending, inviter != invitee, invitee created after invite) + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + // 2. UPDATE consume + mockDbQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + status: 'consumed', + invitee_user_id: INVITEE_ID, + reward_issued: true, + reward_amount: 50, + }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-08-01T10:00:00.000Z', // after invite + 99.99, 'USD', 50, + ); + + expect(result.success).toBe(true); + expect(result.status).toBe('consumed'); + expect(result.rewardIssued).toBe(true); + + // Verify the UPDATE query includes the WHERE status = 'pending' guard + const updateCall = mockDbQuery.mock.calls[1]; + expect(updateCall[0]).toMatch(/WHERE id = .* AND status = 'pending'/); + }); + + it('rejects on race condition: concurrent consume between read and write', async () => { + // 1. Fetch invite (pending) + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + // 2. UPDATE returns 0 rows (another request consumed it first) + mockDbQuery.mockResolvedValueOnce({ rows: [] }); + + const result = await consumeInvite( + INVITE_ID, INVITEE_ID, 'bob@test.com', + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + expect(result.success).toBe(false); + expect(result.rejectionReason).toBe('not_pending'); + expect(result.rejectionDetail).toMatch(/concurrent/); + }); + + it('guard ordering: status check happens before self-invite check', async () => { + // Invite is consumed AND inviter === invitee — should reject on status, not self-invite + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITER_ID, 'surya@test.com', + '2026-08-01T10:00:00.000Z', 99.99, 'USD', 50, + ); + + // Should be not_pending, NOT self_invite (status checked first) + expect(result.rejectionReason).toBe('not_pending'); + }); + + it('guard ordering: self-invite check happens before already-on-platform check', async () => { + // Self-invite AND already on platform — should reject on self_invite + mockDbQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + + const result = await consumeInvite( + INVITE_ID, INVITER_ID, 'surya@test.com', + '2026-06-01T10:00:00.000Z', // before invite + 99.99, 'USD', 50, + ); + + // Should be self_invite, NOT already_on_platform (self checked first) + expect(result.rejectionReason).toBe('self_invite'); + }); +}); diff --git a/src/lib/invite.ts b/src/lib/invite.ts new file mode 100644 index 0000000..129ec3a --- /dev/null +++ b/src/lib/invite.ts @@ -0,0 +1,189 @@ +import { db } from './database.js'; +import type { ConsumeInviteResult, InviteStatus } from '../types/index.js'; + +/** + * Guard 1: Self-invite detection. + * + * The inviter cannot be the same user as the invitee. This is a strict + * identity comparison — if the inviter created the invite link, their + * inviterId is baked into the invite record. When the invitee pays, their + * userId (from the caller's auth system) is compared against inviterId. + * + * This is deterministic and unbeatable: the attacker cannot make their own + * userId differ from the inviterId they chose when creating the invite. + * + * @returns true if this is a self-invite (should be rejected) + */ +export function isSelfInvite(inviterId: string, inviteeUserId: string): boolean { + return inviterId === inviteeUserId; +} + +/** + * Guard 2: Already-on-platform detection. + * + * If the invitee's account was created BEFORE the invite was created, the + * invitee already existed on the platform. The invite is meant to acquire + * NEW users, not credit existing ones. We compare timestamps: + * + * inviteeCreatedAt < inviteCreatedAt → already on platform → reject + * inviteeCreatedAt >= inviteCreatedAt → new user (or same instant) → allow + * + * The boundary case (equal timestamps) is allowed because a user who signed + * up at the exact moment the invite was created is ambiguous, and the + * payment gate already prevents farming. + * + * @returns true if the invitee was already on the platform (should be rejected) + */ +export function isAlreadyOnPlatform( + inviteeCreatedAt: string | Date, + inviteCreatedAt: string | Date +): boolean { + const inviteeTime = new Date(inviteeCreatedAt).getTime(); + const inviteTime = new Date(inviteCreatedAt).getTime(); + return inviteeTime < inviteTime; +} + +/** + * Guard 3: Invite status check. + * + * Only `pending` invites can be consumed. An invite that is already + * `consumed` (invitee already paid and reward was issued) or `expired` + * (manually invalidated) cannot be consumed again. + * + * This is the "invitation expired once consumed" rule: once the invitee + * pays, the invite transitions to `consumed` and all future consume + * attempts are rejected — even from the same device reinstalling. + * + * @returns true if the invite is in a consumable state (pending) + */ +export function isConsumable(status: InviteStatus): boolean { + return status === 'pending'; +} + +/** + * Consume an invite on payment. Runs all three guards in order and, if + * all pass, atomically transitions the invite to `consumed` and marks + * the reward as issued. + * + * Guard ordering (fail fast, cheapest first): + * 1. Status check (DB read, no comparison needed) + * 2. Self-invite (string comparison, no I/O) + * 3. Already-on-platform (timestamp comparison, no I/O) + * + * @param inviteId - UUID of the invite to consume + * @param inviteeUserId - The paying user's ID (from caller's auth system) + * @param inviteeEmail - Optional email for audit trail + * @param inviteeCreatedAt - ISO timestamp of when the invitee's account was created + * @param paymentAmount - The payment amount that triggered consumption + * @param paymentCurrency - 3-letter currency code + * @param rewardAmount - Optional reward amount to record (e.g. 50 for -50%) + * @returns ConsumeInviteResult with success/failure + rejection reason + */ +export async function consumeInvite( + inviteId: string, + inviteeUserId: string, + inviteeEmail: string | undefined, + inviteeCreatedAt: string, + paymentAmount: number, + paymentCurrency: string, + rewardAmount?: number, +): Promise { + // Fetch the invite + const result = await db.query( + `SELECT id, inviter_id, status, created_at FROM invites WHERE id = $1`, + [inviteId], + ); + + if (result.rows.length === 0) { + return { + success: false, + inviteId, + status: 'pending', + rewardIssued: false, + rejectionReason: 'not_found', + rejectionDetail: `Invite ${inviteId} does not exist`, + }; + } + + const invite = result.rows[0]; + + // Guard 3: Status must be pending + if (!isConsumable(invite.status as InviteStatus)) { + return { + success: false, + inviteId, + status: invite.status as InviteStatus, + rewardIssued: false, + rejectionReason: 'not_pending', + rejectionDetail: `Invite is ${invite.status}, not pending`, + }; + } + + // Guard 1: Self-invite + if (isSelfInvite(invite.inviter_id as string, inviteeUserId)) { + return { + success: false, + inviteId, + status: 'pending', + rewardIssued: false, + rejectionReason: 'self_invite', + rejectionDetail: `Inviter ${invite.inviter_id} cannot invite themselves`, + }; + } + + // Guard 2: Already on platform + if (isAlreadyOnPlatform(inviteeCreatedAt, invite.created_at as string)) { + return { + success: false, + inviteId, + status: 'pending', + rewardIssued: false, + rejectionReason: 'already_on_platform', + rejectionDetail: `Invitee account created before invite — already on platform`, + }; + } + + // All guards passed — consume the invite atomically + const consumeResult = await db.query( + `UPDATE invites + SET status = 'consumed', + invitee_user_id = $1, + invitee_email = $2, + consumed_at = NOW(), + payment_amount = $3, + payment_currency = $4, + reward_issued = true, + reward_amount = $5, + updated_at = NOW() + WHERE id = $6 AND status = 'pending' + RETURNING *`, + [ + inviteeUserId, + inviteeEmail || null, + paymentAmount, + paymentCurrency, + rewardAmount || null, + inviteId, + ], + ); + + if (consumeResult.rows.length === 0) { + // Race condition: another request consumed it between our read and write. + // The WHERE status = 'pending' guard in the UPDATE prevented a double-consume. + return { + success: false, + inviteId, + status: 'consumed', + rewardIssued: false, + rejectionReason: 'not_pending', + rejectionDetail: `Invite was consumed by a concurrent request`, + }; + } + + return { + success: true, + inviteId, + status: 'consumed', + rewardIssued: true, + }; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index d48bfcd..c0dd635 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,3 +8,4 @@ export { templateRoutes } from './templates.js'; export { previewRoutes } from './preview.js'; export { debugRoutes } from './debug.js'; export { wellKnownRoutes } from './well-known.js'; +export { inviteRoutes } from './invites.js'; diff --git a/src/routes/invites.test.ts b/src/routes/invites.test.ts new file mode 100644 index 0000000..be394bc --- /dev/null +++ b/src/routes/invites.test.ts @@ -0,0 +1,506 @@ +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; + +// Mock the database singleton so the route runs without a real Postgres. +vi.mock('../lib/database.js', () => ({ + db: { query: vi.fn() }, +})); + +// Mock the webhook trigger so it doesn't attempt real HTTP calls. +vi.mock('../lib/webhook.js', () => ({ + triggerWebhooks: vi.fn().mockResolvedValue(undefined), +})); + +import Fastify, { type FastifyInstance } from 'fastify'; +import { db } from '../lib/database.js'; +import { triggerWebhooks } from '../lib/webhook.js'; +import { inviteRoutes } from './invites.js'; + +const mockQuery = db.query as unknown as Mock; +const mockTriggerWebhooks = triggerWebhooks as unknown as Mock; + +const INVITE_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const INVITER_ID = 'user-surya'; +const INVITEE_ID = 'user-bob'; +const LINK_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const INVITE_CREATED_AT = '2026-07-01T10:00:00.000Z'; + +async function buildApp(): Promise { + const app = Fastify(); + await app.register(inviteRoutes); + await app.ready(); + return app; +} + +describe('POST /api/invites — create invite', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + it('creates a pending invite with inviterId and inviterName', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + link_id: null, + status: 'pending', + metadata: {}, + created_at: INVITE_CREATED_AT, + }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/invites', + payload: { inviterId: INVITER_ID, inviterName: 'Surya' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.id).toBe(INVITE_ID); + expect(body.inviterId).toBe(INVITER_ID); + expect(body.inviterName).toBe('Surya'); + expect(body.status).toBe('pending'); + + // Verify INSERT was called with correct params + const insertCall = mockQuery.mock.calls[0]; + expect(insertCall[0]).toMatch(/INSERT INTO invites/); + expect(insertCall[1]).toEqual([INVITER_ID, 'Surya', null, '{}']); + + await app.close(); + }); + + it('creates an invite linked to a LinkForty link with metadata', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + link_id: LINK_ID, + status: 'pending', + metadata: { campaign: 'summer' }, + created_at: INVITE_CREATED_AT, + }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/invites', + payload: { + inviterId: INVITER_ID, + inviterName: 'Surya', + linkId: LINK_ID, + metadata: { campaign: 'summer' }, + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().linkId).toBe(LINK_ID); + + await app.close(); + }); + + it('rejects when inviterId is missing', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/invites', + payload: { inviterName: 'Surya' }, + }); + + expect(res.statusCode).toBe(400); + await app.close(); + }); +}); + +describe('POST /api/invites/:id/consume — payment-gated consumption', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockTriggerWebhooks.mockClear(); + }); + + const validConsumePayload = { + inviteeUserId: INVITEE_ID, + inviteeEmail: 'bob@test.com', + inviteeCreatedAt: '2026-08-01T10:00:00.000Z', + paymentAmount: 99.99, + paymentCurrency: 'USD', + rewardAmount: 50, + }; + + it('succeeds with 200 when all guards pass and fires invite_consumed_event webhook', async () => { + // 1. consumeInvite: fetch invite + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + // 2. consumeInvite: UPDATE consume + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', reward_issued: true }], + }); + // 3. webhook: fetch full invite + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + invitee_user_id: INVITEE_ID, + invitee_email: 'bob@test.com', + payment_amount: '99.99', + payment_currency: 'USD', + reward_issued: true, + reward_amount: '50', + consumed_at: '2026-08-15T10:00:00.000Z', + }], + }); + // 4. webhook: fetch webhooks for inviter + mockQuery.mockResolvedValueOnce({ + rows: [{ id: 'wh-1', url: 'https://example.com/hook', secret: 's', events: ['invite_consumed_event'], is_active: true }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: validConsumePayload, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.rewardIssued).toBe(true); + expect(body.status).toBe('consumed'); + + // Webhook was triggered with invite_consumed_event + expect(mockTriggerWebhooks).toHaveBeenCalledTimes(1); + const webhookCall = mockTriggerWebhooks.mock.calls[0]; + expect(webhookCall[1]).toBe('invite_consumed_event'); + + await app.close(); + }); + + it('returns 403 with self_invite when inviter === invitee', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: { ...validConsumePayload, inviteeUserId: INVITER_ID }, // self-invite + }); + + expect(res.statusCode).toBe(403); + expect(res.json().rejectionReason).toBe('self_invite'); + expect(mockTriggerWebhooks).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('returns 403 with already_on_platform when invitee existed before invite', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: { ...validConsumePayload, inviteeCreatedAt: '2026-06-01T10:00:00.000Z' }, // before invite + }); + + expect(res.statusCode).toBe(403); + expect(res.json().rejectionReason).toBe('already_on_platform'); + expect(mockTriggerWebhooks).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('returns 403 with not_pending when invite is already consumed', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', created_at: INVITE_CREATED_AT }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: validConsumePayload, + }); + + expect(res.statusCode).toBe(403); + expect(res.json().rejectionReason).toBe('not_pending'); + expect(mockTriggerWebhooks).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('returns 404 when invite does not exist', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: validConsumePayload, + }); + + expect(res.statusCode).toBe(404); + expect(res.json().rejectionReason).toBe('not_found'); + + await app.close(); + }); + + it('rejects with 400 when paymentAmount is missing or non-positive', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: { ...validConsumePayload, paymentAmount: 0 }, + }); + + expect(res.statusCode).toBe(400); + + await app.close(); + }); +}); + +describe('GET /api/invites/:inviterId — list invites', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + it('returns all invites for an inviter ordered by created_at DESC', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + link_id: null, + status: 'consumed', + invitee_user_id: INVITEE_ID, + invitee_email: 'bob@test.com', + consumed_at: '2026-08-15T10:00:00.000Z', + payment_amount: '99.99', + payment_currency: 'USD', + reward_issued: true, + reward_amount: '50', + metadata: {}, + created_at: INVITE_CREATED_AT, + updated_at: '2026-08-15T10:00:00.000Z', + }, + { + id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + inviter_id: INVITER_ID, + inviter_name: 'Surya', + link_id: null, + status: 'pending', + invitee_user_id: null, + invitee_email: null, + consumed_at: null, + payment_amount: null, + payment_currency: null, + reward_issued: false, + reward_amount: null, + metadata: {}, + created_at: '2026-07-15T10:00:00.000Z', + updated_at: '2026-07-15T10:00:00.000Z', + }, + ], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: `/api/invites/${INVITER_ID}`, + }); + + expect(res.statusCode).toBe(200); + const invites = res.json(); + expect(invites).toHaveLength(2); + expect(invites[0].status).toBe('consumed'); + expect(invites[0].paymentAmount).toBe(99.99); // parsed from string + expect(invites[1].status).toBe('pending'); + expect(invites[1].paymentAmount).toBeNull(); + + await app.close(); + }); + + it('returns empty array when inviter has no invites', async () => { + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/invites/nonexistent-user', + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + + await app.close(); + }); +}); + +describe('POST /api/invites/:id/expire — manual expiry', () => { + beforeEach(() => { + mockQuery.mockReset(); + }); + + it('expires a pending invite', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + status: 'expired', + updated_at: '2026-07-20T10:00:00.000Z', + }], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/expire`, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('expired'); + + await app.close(); + }); + + it('returns 404 when invite does not exist', async () => { + // UPDATE returns 0 rows + mockQuery.mockResolvedValueOnce({ rows: [] }); + // Then the check query + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/expire`, + }); + + expect(res.statusCode).toBe(404); + + await app.close(); + }); + + it('returns 409 when invite is not pending (already consumed)', async () => { + // UPDATE returns 0 rows (status != pending) + mockQuery.mockResolvedValueOnce({ rows: [] }); + // Check query returns the invite with consumed status + mockQuery.mockResolvedValueOnce({ rows: [{ status: 'consumed' }] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/expire`, + }); + + expect(res.statusCode).toBe(409); + expect(res.json().status).toBe('consumed'); + + await app.close(); + }); +}); + +describe('Full invite lifecycle: create → consume → verify consumed', () => { + beforeEach(() => { + mockQuery.mockReset(); + mockTriggerWebhooks.mockClear(); + }); + + it('create invite, then consume on payment, then verify it shows as consumed in list', async () => { + const app = await buildApp(); + + // Step 1: Create invite + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + link_id: null, + status: 'pending', + metadata: {}, + created_at: INVITE_CREATED_AT, + }], + }); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/invites', + payload: { inviterId: INVITER_ID, inviterName: 'Surya' }, + }); + expect(createRes.statusCode).toBe(200); + expect(createRes.json().status).toBe('pending'); + + // Step 2: Consume on payment (all guards pass) + // 2a. fetch invite + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'pending', created_at: INVITE_CREATED_AT }], + }); + // 2b. UPDATE consume + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', reward_issued: true }], + }); + // 2c. webhook: fetch full invite + mockQuery.mockResolvedValueOnce({ + rows: [{ + id: INVITE_ID, + inviter_id: INVITER_ID, + inviter_name: 'Surya', + invitee_user_id: INVITEE_ID, + invitee_email: 'bob@test.com', + payment_amount: '99.99', + payment_currency: 'USD', + reward_issued: true, + reward_amount: '50', + consumed_at: '2026-08-15T10:00:00.000Z', + }], + }); + // 2d. webhook: fetch webhooks (none configured) + mockQuery.mockResolvedValueOnce({ rows: [] }); + + const consumeRes = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: { + inviteeUserId: INVITEE_ID, + inviteeEmail: 'bob@test.com', + inviteeCreatedAt: '2026-08-01T10:00:00.000Z', + paymentAmount: 99.99, + paymentCurrency: 'USD', + rewardAmount: 50, + }, + }); + expect(consumeRes.statusCode).toBe(200); + expect(consumeRes.json().success).toBe(true); + expect(consumeRes.json().rewardIssued).toBe(true); + + // Step 3: Verify — second consume attempt is rejected (not_pending) + mockQuery.mockResolvedValueOnce({ + rows: [{ id: INVITE_ID, inviter_id: INVITER_ID, status: 'consumed', created_at: INVITE_CREATED_AT }], + }); + + const secondConsumeRes = await app.inject({ + method: 'POST', + url: `/api/invites/${INVITE_ID}/consume`, + payload: { + inviteeUserId: INVITEE_ID, + inviteeEmail: 'bob@test.com', + inviteeCreatedAt: '2026-08-01T10:00:00.000Z', + paymentAmount: 99.99, + paymentCurrency: 'USD', + rewardAmount: 50, + }, + }); + expect(secondConsumeRes.statusCode).toBe(403); + expect(secondConsumeRes.json().rejectionReason).toBe('not_pending'); + + await app.close(); + }); +}); diff --git a/src/routes/invites.ts b/src/routes/invites.ts new file mode 100644 index 0000000..a1a2684 --- /dev/null +++ b/src/routes/invites.ts @@ -0,0 +1,234 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { z, ZodError } from 'zod'; +import { db } from '../lib/database.js'; +import { consumeInvite } from '../lib/invite.js'; +import { triggerWebhooks } from '../lib/webhook.js'; + +const createInviteSchema = z.object({ + inviterId: z.string().min(1, 'inviterId is required'), + inviterName: z.string().optional(), + linkId: z.string().uuid().optional(), + metadata: z.record(z.string(), z.any()).optional(), +}); + +const consumeInviteSchema = z.object({ + inviteeUserId: z.string().min(1, 'inviteeUserId is required'), + inviteeEmail: z.string().email().optional(), + inviteeCreatedAt: z.string().datetime('inviteeCreatedAt must be an ISO 8601 datetime'), + paymentAmount: z.number().positive('paymentAmount must be positive'), + paymentCurrency: z.string().length(3, 'paymentCurrency must be a 3-letter code'), + rewardAmount: z.number().optional(), +}); + +export async function inviteRoutes(fastify: FastifyInstance) { + // Convert ZodError into a 400 response instead of Fastify's default 500. + fastify.setErrorHandler((error, _request, reply) => { + if (error instanceof ZodError) { + return reply.status(400).send({ + error: 'Validation failed', + details: error.issues.map(i => ({ path: i.path.join('.'), message: i.message })), + }); + } + return reply.send(error); + }); + + // Create a new invite + fastify.post('/api/invites', async (request: FastifyRequest) => { + const data = createInviteSchema.parse(request.body); + + const result = await db.query( + `INSERT INTO invites (inviter_id, inviter_name, link_id, metadata) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [ + data.inviterId, + data.inviterName || null, + data.linkId || null, + JSON.stringify(data.metadata || {}), + ], + ); + + const invite = result.rows[0]; + return { + id: invite.id, + inviterId: invite.inviter_id, + inviterName: invite.inviter_name, + linkId: invite.link_id, + status: invite.status, + metadata: invite.metadata, + createdAt: invite.created_at, + }; + }); + + // Consume an invite on payment — runs all three guards + fastify.post('/api/invites/:id/consume', async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + const data = consumeInviteSchema.parse(request.body); + + const result = await consumeInvite( + id, + data.inviteeUserId, + data.inviteeEmail, + data.inviteeCreatedAt, + data.paymentAmount, + data.paymentCurrency, + data.rewardAmount, + ); + + // If consumption succeeded, fire invite_consumed_event webhooks + if (result.success) { + try { + // Fetch the full invite record for the webhook payload + const inviteResult = await db.query( + `SELECT * FROM invites WHERE id = $1`, + [id], + ); + + if (inviteResult.rows.length > 0) { + const invite = inviteResult.rows[0]; + const webhookPayload = { + id: invite.id, + inviterId: invite.inviter_id, + inviterName: invite.inviter_name, + inviteeUserId: invite.invitee_user_id, + inviteeEmail: invite.invitee_email, + paymentAmount: parseFloat(invite.payment_amount), + paymentCurrency: invite.payment_currency, + rewardIssued: invite.reward_issued, + rewardAmount: invite.reward_amount ? parseFloat(invite.reward_amount) : null, + consumedAt: invite.consumed_at, + }; + + // Look up webhooks for the inviter's user_id + const webhooksResult = await db.query( + `SELECT * FROM webhooks WHERE user_id = $1 AND is_active = true`, + [invite.inviter_id], + ); + + if (webhooksResult.rows.length > 0) { + await triggerWebhooks( + webhooksResult.rows, + 'invite_consumed_event', + invite.id, + webhookPayload, + ); + } + } + } catch (webhookError) { + // Webhook delivery failure should not affect the consume result + fastify.log.error(`Error triggering invite webhooks: ${webhookError}`); + } + } + + const statusCode = result.success ? 200 + : result.rejectionReason === 'not_found' ? 404 + : 403; + + return reply.status(statusCode).send(result); + }); + + // Get all invites for an inviter + fastify.get('/api/invites/:inviterId', async (request: FastifyRequest<{ + Params: { inviterId: string }; + }>) => { + const { inviterId } = request.params; + + const result = await db.query( + `SELECT * FROM invites WHERE inviter_id = $1 ORDER BY created_at DESC`, + [inviterId], + ); + + return result.rows.map(row => ({ + id: row.id, + inviterId: row.inviter_id, + inviterName: row.inviter_name, + linkId: row.link_id, + status: row.status, + inviteeUserId: row.invitee_user_id, + inviteeEmail: row.invitee_email, + consumedAt: row.consumed_at, + paymentAmount: row.payment_amount ? parseFloat(row.payment_amount) : null, + paymentCurrency: row.payment_currency, + rewardIssued: row.reward_issued, + rewardAmount: row.reward_amount ? parseFloat(row.reward_amount) : null, + metadata: row.metadata, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); + }); + + // Get a single invite by ID + fastify.get('/api/invites/id/:id', async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + + const result = await db.query( + `SELECT * FROM invites WHERE id = $1`, + [id], + ); + + if (result.rows.length === 0) { + return reply.status(404).send({ error: 'Invite not found' }); + } + + const row = result.rows[0]; + return { + id: row.id, + inviterId: row.inviter_id, + inviterName: row.inviter_name, + linkId: row.link_id, + status: row.status, + inviteeUserId: row.invitee_user_id, + inviteeEmail: row.invitee_email, + consumedAt: row.consumed_at, + paymentAmount: row.payment_amount ? parseFloat(row.payment_amount) : null, + paymentCurrency: row.payment_currency, + rewardIssued: row.reward_issued, + rewardAmount: row.reward_amount ? parseFloat(row.reward_amount) : null, + metadata: row.metadata, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + }); + + // Manually expire an invite (no reward issued) + fastify.post('/api/invites/:id/expire', async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + const { id } = request.params; + + const result = await db.query( + `UPDATE invites + SET status = 'expired', updated_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id], + ); + + if (result.rows.length === 0) { + // Either not found or not in pending state + const check = await db.query(`SELECT status FROM invites WHERE id = $1`, [id]); + if (check.rows.length === 0) { + return reply.status(404).send({ error: 'Invite not found' }); + } + return reply.status(409).send({ + error: 'Invite is not pending', + status: check.rows[0].status, + }); + } + + const row = result.rows[0]; + return { + id: row.id, + inviterId: row.inviter_id, + status: row.status, + updatedAt: row.updated_at, + }; + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index c7ac18f..f17b63d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -186,7 +186,7 @@ export interface AnalyticsData { * Discriminated event type sent in webhook payloads. * Consumers should filter webhooks by subscribing to specific event types. */ -export type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event' | 'sdk_event'; +export type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event' | 'sdk_event' | 'invite_consumed_event'; /** * A registered webhook endpoint that receives event notifications from LinkForty. @@ -278,3 +278,76 @@ export interface ConversionEvent { timestamp: string; } +// Invite types + +/** + * Lifecycle status of a referral invite. + * - `pending`: invite created, invitee has not yet paid. + * - `consumed`: invitee paid, reward issued to inviter. + * - `expired`: manually expired by the inviter or admin (no reward). + */ +export type InviteStatus = 'pending' | 'consumed' | 'expired'; + +/** + * A referral invite record. Created when a user shares an invite link, + * consumed when the invitee makes a payment. The reward is gated on + * payment, not signup, to prevent fake-account farming. + */ +export interface Invite { + id: string; + inviterId: string; + inviterName?: string; + linkId?: string; + status: InviteStatus; + inviteeUserId?: string; + inviteeEmail?: string; + consumedAt?: string; + paymentAmount?: number; + paymentCurrency?: string; + rewardIssued: boolean; + rewardAmount?: number; + metadata: Record; + createdAt: string; + updatedAt: string; +} + +/** + * Request body for creating a new invite. + */ +export interface CreateInviteRequest { + inviterId: string; + inviterName?: string; + linkId?: string; + metadata?: Record; +} + +/** + * Request body for consuming an invite on payment. + * + * The `inviteeCreatedAt` timestamp is the invitee's account creation time + * from the caller's auth system. It is compared against the invite's + * `createdAt` to reject users who already existed on the platform before + * the invite was created (already-on-platform guard). + */ +export interface ConsumeInviteRequest { + inviteeUserId: string; + inviteeEmail?: string; + inviteeCreatedAt: string; + paymentAmount: number; + paymentCurrency: string; + rewardAmount?: number; +} + +/** + * Result of a consume attempt, with a machine-readable rejection reason + * when guards fail. + */ +export interface ConsumeInviteResult { + success: boolean; + inviteId: string; + status: InviteStatus; + rewardIssued: boolean; + rejectionReason?: 'self_invite' | 'already_on_platform' | 'not_pending' | 'not_found'; + rejectionDetail?: string; +} +