diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..1eacd85 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,599 @@ +# RefRef — Local Development & Testing Handbook + +Everything needed to install, configure, run and test this monorepo locally. + +> For the product overview and feature list, see [README.md](README.md). For the +> Welfie referral fork design, see [WELFIE_FORK_IMPLEMENTATION.md](WELFIE_FORK_IMPLEMENTATION.md). + +--- + +## Table of contents + +1. [Architecture at a glance](#1-architecture-at-a-glance) +2. [Prerequisites](#2-prerequisites) +3. [Path A — Docker Compose (fastest)](#3-path-a--docker-compose-fastest) +4. [Path B — Local pnpm development](#4-path-b--local-pnpm-development) +5. [Environment variables](#5-environment-variables) +6. [Database workflow](#6-database-workflow) +7. [Testing handbook](#7-testing-handbook) +8. [Manual test recipes](#8-manual-test-recipes) +9. [Inspecting state](#9-inspecting-state) +10. [Troubleshooting](#10-troubleshooting) + +--- + +## 1. Architecture at a glance + +A pnpm + Turborepo monorepo. `apps/*` are deployable services, `packages/*` are +shared libraries. + +| App | Package | Default port | Purpose | +| -------------- | ---------------- | ------------ | ------------------------------------------------------------- | +| `apps/webapp` | `@refref/webapp` | 3000 | Next.js admin portal, auth, tRPC API | +| `apps/api` | `@refref/api` | 3001 | Fastify public API (widget init, signup tracking, hand-off) | +| `apps/refer` | `@refref/refer` | 3002 | Fastify public referral service — redirects, invite/join forms | +| `apps/acme` | `@refref/acme` | 3003 (`start`) | Demo merchant app for exercising the widget | +| `apps/assets` | `@refref/assets` | 8787 | Cloudflare Worker serving `widget.v1.js` / `attribution.v1.js` | +| `apps/www` | `@refref/www` | — | Marketing site + docs (Fumadocs) | +| `apps/e2e` | `@refref/e2e` | — | Playwright end-to-end suite | + +Key shared packages: `@refref/coredb` (Drizzle schema + seed), `@refref/types` +(Zod schemas), `@refref/utils` (refcode generation), `@refref/webhooks`, +`@refref/id`, `@refref/auth`, `@refref/ui`. + +### The referral flow + +```mermaid +flowchart TD + A["Referrer gets a link"] --> A1["Widget: POST /v1/widget/init (JWT)"] + A --> A2["Anonymous: GET/POST /refer/join"] + A1 --> B["refcode row, e.g. yaymdja"] + A2 --> B + B --> C["Share http://localhost:3002/yaymdja"] + C --> D["GET /:code → 307"] + D --> E["/refer/invite/yaymdja — personalised form"] + E --> F["POST → referral_lead + handoff_token"] + F --> G["303 to signup?lt=TOKEN"] + G --> H["POST /v1/handoff/exchange → referral"] +``` + +Two ways a referrer obtains a link: + +- **Authenticated** — the host product's frontend calls `POST /v1/widget/init` + on `apps/api` with a product-signed JWT. +- **Anonymous** — a visitor fills the public form at `{REFER_BASE_PATH}/join`, + identified only by email (`participant.external_id = "anon:"`). + +Both mint a `refcode`. Visiting that code redirects to the invite form, which +captures the referee and hands off to signup with a single-use token. + +--- + +## 2. Prerequisites + +| Tool | Version | Notes | +| ---------- | -------------------- | --------------------------------------------------------- | +| Node.js | >= 20 (22 LTS tested) | Enforced by `engines` in the root `package.json` | +| pnpm | 10.23.0 | Pinned via `packageManager`; Corepack will fetch it | +| PostgreSQL | 16 | Or just use the Docker Compose `postgres` service | +| Docker | Desktop 4.x+ | Required for Path A | +| portless | latest | Optional. `npm install -g portless` | +| Infisical | latest | Optional. Scripts fall back gracefully when absent | + +Enable Corepack once (needs network on first `pnpm` invocation): + +```bash +corepack enable +``` + +### About the optional tools + +Many `package.json` scripts are written as: + +``` +infisical run --env=dev -- || +``` + +If the Infisical CLI is missing the first half fails and the plain command runs, +so **you do not need Infisical**. Supply env vars via `.env` files instead. + +`portless` gives each app a stable `*.localhost:1355` hostname. Skip it with +`PORTLESS=0 pnpm dev` and use raw ports. + +--- + +## 3. Path A — Docker Compose (fastest) + +Best for exercising the referral flow without a local toolchain. + +```bash +git clone https://github.com/refrefhq/refref.git +cd refref + +# Postgres + webapp (runs db:push and db:seed automatically on boot) +docker compose up -d postgres webapp + +# The public referral service +docker compose up -d --build refer +``` + +Services: + +| URL | What | +| ----------------------- | ---------------------------- | +| http://localhost:3000 | Webapp portal | +| http://localhost:3002 | Refer service | +| `localhost:5432` | Postgres (`postgres`/`postgres`) | + +Verify: + +```bash +curl -s http://localhost:3002/health +# {"status":"ok","timestamp":"...","service":"refer"} + +docker compose logs -f refer +``` + +Tear down (`-v` also drops the database volume): + +```bash +docker compose down # keep data +docker compose down -v # wipe data +``` + +### Rebuilding after a code change + +The `refer` image is built from source, so changes require a rebuild: + +```bash +docker compose build refer && docker compose up -d refer +``` + +> `apps/api`, `apps/assets` and `apps/acme` are **not** in `docker-compose.yml`. +> Run them via Path B if you need them. `Dockerfile.api` and `Dockerfile.refer` +> exist for deployment. + +--- + +## 4. Path B — Local pnpm development + +### 4.1 Install + +```bash +pnpm install +``` + +### 4.2 Start Postgres + +Reuse the Compose database rather than installing Postgres: + +```bash +docker compose up -d postgres +``` + +### 4.3 Configure environment + +```bash +cp apps/webapp/.env.example apps/webapp/.env +cp apps/api/.env.example apps/api/.env +cp apps/refer/.env.example apps/refer/.env + +# Required by the webapp +openssl rand -base64 32 # paste into BETTER_AUTH_SECRET +``` + +### 4.4 Create the schema and seed + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/refref" + +pnpm -F @refref/coredb db:push +pnpm -F @refref/coredb db:seed +``` + +### 4.5 Run + +```bash +# Everything +pnpm dev + +# Or a single service +pnpm -F @refref/refer dev +pnpm -F @refref/api dev +pnpm -F @refref/webapp dev +``` + +With portless running: + +| App | URL | +| ------ | ----------------------------------- | +| Webapp | http://refref-webapp.localhost:1355 | +| WWW | http://refref-www.localhost:1355 | +| API | http://refref-api.localhost:1355 | +| Refer | http://refref-refer.localhost:1355 | +| Acme | http://refref-acme.localhost:1355 | + +Without portless (`PORTLESS=0 pnpm dev`), use `localhost:3000/3001/3002/3003`. + +### 4.6 Common commands + +```bash +pnpm build # turbo run build +pnpm lint +pnpm type:check +pnpm format +pnpm test # turbo run test across all packages + +# Scope to one package +pnpm -F @refref/refer type:check +pnpm -F @refref/refer test +``` + +--- + +## 5. Environment variables + +### Required + +| Variable | Used by | Notes | +| -------------------- | -------------- | ---------------------------------------- | +| `DATABASE_URL` | all | `postgresql://postgres:postgres@localhost:5432/refref` | +| `BETTER_AUTH_SECRET` | webapp | `openssl rand -base64 32` | + +### `apps/refer` + +See [apps/refer/.env.example](apps/refer/.env.example). + +| Variable | Default | Notes | +| -------------------------- | -------------------------- | -------------------------------------------------------------- | +| `PORT` | `3002` | | +| `NODE_ENV` | — | Must be `production` in the Docker image (see Troubleshooting) | +| `REFER_BASE_PATH` | `""` | Proxy prefix, e.g. `/refer`. Applies to `/invite` and `/join` | +| `REFERRAL_HOST_URL` | `http://localhost:3002` | Origin used to build referral links. **No trailing slash, no base path** | +| `WELFIE_SIGNUP_URL` | `https://welfie.com/signup`| Where the invite form hands off | +| `PRIVACY_POLICY_URL` | `https://welfie.com/privacy`| Linked beside the consent checkbox | +| `REFERRAL_CONSENT_VERSION` | `privacy-2026-08` | Stored with every lead | +| `HANDOFF_TTL_MINUTES` | `10` | Single-use token lifetime | +| `DISPOSABLE_DOMAINS_EXTRA` | `""` | Comma/space separated additions to the blocklist | +| `TURNSTILE_SITE_KEY` | — | Renders the CAPTCHA widget when set | +| `TURNSTILE_SECRET` | — | **CAPTCHA is only enforced when this is set.** Leave empty locally | + +> `REFERRAL_HOST_URL` must **not** include `REFER_BASE_PATH`. The `/:code` +> redirect is registered at the root, so links look like +> `http://localhost:3002/yaymdja` even when `REFER_BASE_PATH=/refer`. +> Keep the value identical in `apps/api` and `apps/refer`, or the two services +> will hand out different link formats. + +### `apps/api` + +See [apps/api/.env.example](apps/api/.env.example): `PORT` (3001), +`REFERRAL_HOST_URL`, `WEBHOOK_WORKER_ENABLED`, `WEBHOOK_WORKER_INTERVAL_MS`, +`ATTRIBUTION_WINDOW_DAYS`. + +### `apps/webapp` + +See [apps/webapp/.env.example](apps/webapp/.env.example). Optional: +`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET`, `RESEND_API_KEY`, +`NEXT_PUBLIC_POSTHOG_KEY`, `NEXT_PUBLIC_ASSETS_URL`. + +--- + +## 6. Database workflow + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/refref" + +pnpm -F @refref/coredb db:push # sync schema directly (dev) +pnpm -F @refref/coredb db:generate # emit a migration from schema.ts +pnpm -F @refref/coredb db:migrate # apply migrations +pnpm -F @refref/coredb db:studio # Drizzle Studio GUI +pnpm -F @refref/coredb db:seed # insert fixtures +pnpm -F @refref/coredb db:deleteseed # remove fixtures +``` + +Schema lives in [packages/coredb/src/schema.ts](packages/coredb/src/schema.ts); +migrations in [packages/coredb/drizzle](packages/coredb/drizzle). + +Applying a single migration to a running container: + +```bash +docker exec -i refref-db psql -U postgres -d refref \ + < packages/coredb/drizzle/0004_nostalgic_multiple_man.sql +``` + +### Seed fixtures you will actually use + +From [packages/coredb/src/seed.ts](packages/coredb/src/seed.ts): + +| Entity | ID / value | +| ----------------------- | ------------------------------ | +| Product ("Demo SaaS") | `prd_rfl78apntmdzwuyxykqd7ait` | +| Active program | `prg_bi46lm8q7llax4khniewcvlb` | +| Seeded refcode | `rpetnw5` | +| Login user | `test1@test.com` | + +Only `prg_bi46lm8q7llax4khniewcvlb` has `status = 'active'`, and **the join and +invite pages only ever resolve active programs**. + +The seed does not populate `brandConfig`, which supplies the form's accent +colour. Add it if you want branded pages: + +```sql +UPDATE program SET config = jsonb_set(config::jsonb, '{brandConfig}', + '{"primaryColor":"#0f9d58","landingPageUrl":"http://localhost:3000/auth/sign-up"}'::jsonb, + true) +WHERE id = 'prg_bi46lm8q7llax4khniewcvlb'; +``` + +--- + +## 7. Testing handbook + +### 7.1 The layers + +| Layer | Tool | Needs a database? | Command | +| ----------------- | ---------- | ----------------- | -------------------------------- | +| Unit / route | Vitest | No — mocked | `pnpm -F @refref/refer test` | +| Integration | tsx script | **Yes**, seeded | `pnpm -F @refref/refer itest` | +| End-to-end | Playwright | **Yes**, seeded | `pnpm -F @refref/e2e test` | +| Types | tsc | No | `pnpm type:check` | + +### 7.2 Unit and route tests + +```bash +pnpm test # everything, via turbo +pnpm -F @refref/refer test # one package +pnpm -F @refref/refer test:watch +pnpm -F @refref/refer test:ui +``` + +The database is mocked in [apps/refer/test/setup.ts](apps/refer/test/setup.ts), +which replaces `@refref/coredb`'s `createDb` with a Vitest mock. Each test +primes the queries it needs: + +```ts +mockDb.query.refcode.findFirst.mockResolvedValueOnce({ + id: "rc_happy", + code: "abc1234", + participant: { id: "prt_123", name: "John Doe", email: "john@example.com" }, +}); +``` + +`vitest.config.ts` pins `NODE_ENV=test` and a dummy `DATABASE_URL`, so **no +Postgres is required** and `REFER_BASE_PATH` is empty — meaning redirect targets +are `/invite/`, not `/refer/invite/`. Existing assertions use +`toMatch(/\/invite\/abc1234(\?|$)/)` so they hold under either base path. + +Current suites: `abuse.test.ts` (9), `health.test.ts` (6), +`referral-redirect.test.ts` (13) — 28 tests. + +### 7.3 Integration test (real database) + +[apps/refer/scripts/itest.mts](apps/refer/scripts/itest.mts) boots the real app +against real Postgres and drives lead → webhook → hand-off → qualified, +including HMAC signature verification and a forced delivery retry. + +```bash +export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/refref" +pnpm -F @refref/coredb db:seed # requires refcode rpetnw5 +pnpm -F @refref/refer itest +``` + +Exits non-zero on any failed assertion. + +### 7.4 End-to-end (Playwright) + +```bash +cd apps/e2e +pnpm exec playwright install # first run only +pnpm test +pnpm test:ui # interactive +pnpm test:headed +pnpm cleanup-ports # frees 3000-3003, 8787 +``` + +### 7.5 Before opening a PR + +```bash +pnpm type:check +pnpm lint +pnpm test +``` + +> `pnpm build` at the root currently fails on `@refref/webapp` with +> `Invalid environment variables` unless the webapp's env is populated — its +> `env.ts` validates at build time. Build a single package to sidestep this: +> `pnpm exec turbo run build --filter=@refref/refer...` + +--- + +## 8. Manual test recipes + +Assumes Path A with `REFER_BASE_PATH=/refer` and the seeded product +`prd_rfl78apntmdzwuyxykqd7ait`. + +### Recipe 1 — Anonymous referrer gets a link + +```bash +PID=prd_rfl78apntmdzwuyxykqd7ait + +# 1. Load the public form (no auth, no cookies) +curl -s -o /dev/null -w "%{http_code}\n" \ + "http://localhost:3002/refer/join?productId=$PID" # 200 + +# 2. Submit it +curl -s -X POST http://localhost:3002/refer/join \ + -d "productId=$PID" -d "firstName=Ramya" \ + -d "email=ramya@example.com" -d "consent=yes" \ + | grep -oE 'Referral code: [a-z0-9]+' +``` + +Re-submitting the same email returns the **same** code — the participant is +upserted on `(product_id, external_id)`. + +### Recipe 2 — Referee journey + +```bash +CODE= + +# The link lands on the personalised form, forwarding utm params +curl -s -o /dev/null -D - \ + "http://localhost:3002/$CODE?utm_source=whatsapp" | grep -i location +# location: /refer/invite/?utm_source=whatsapp + +# Submit referee details -> 303 to signup with a single-use hand-off token +curl -s -o /dev/null -D - -X POST "http://localhost:3002/refer/invite/$CODE" \ + -d "firstName=Arjun" -d "lastName=Mehta" \ + -d "email=arjun@gmail.com" --data-urlencode "phone=+61 400 123 456" \ + -d "consent=yes" | grep -i location +# location: http://localhost:3000/auth/sign-up?lt= +``` + +> **Gotcha:** the referee's address must differ from the referrer's. Only an +> exact match is blocked (`same_email`) — colleagues on a shared company domain +> are allowed. See [apps/refer/src/lib/abuse.ts](apps/refer/src/lib/abuse.ts). + +### Recipe 3 — Abuse controls + +Each must return `400` and write **no** rows: + +```bash +U=http://localhost:3002/refer/join +P=productId=prd_rfl78apntmdzwuyxykqd7ait + +curl -s -X POST $U -d "$P" -d "email=b@x.com" -d "consent=yes" -d "company=Bot" # honeypot +curl -s -X POST $U -d "$P" -d "email=x@mailinator.com" -d "consent=yes" # disposable +curl -s -X POST $U -d "$P" -d "email=n@x.com" # no consent +curl -s -X POST $U -d "$P" -d "email=not-an-email" -d "consent=yes" # bad email +``` + +Unknown or missing `productId` returns `404`. + +### Recipe 4 — Rate limits + +| Route | Limit | +| ------------------------ | -------- | +| `GET /:code` | 100/min | +| `GET .../join` | 60/min | +| `POST .../join` | 10/min | +| `GET .../invite/:code` | 60/min | +| `POST .../invite/:code` | 20/min | +| Global default | 100/min | + +```bash +for i in $(seq 1 12); do + curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3002/refer/join \ + -d "productId=prd_rfl78apntmdzwuyxykqd7ait" \ + -d "email=flood$i@example.com" -d "consent=yes" +done +# ... 200 200 429 429 +``` + +Limits are per IP and reset after the window. **Remember you consumed quota +while testing** — a later `429` may be your own earlier requests. + +### Recipe 5 — Browser walkthrough + +1. Open `http://localhost:3002/refer/join?productId=prd_rfl78apntmdzwuyxykqd7ait` +2. Fill first name + email, tick consent, submit → success page with a Copy button +3. Open the returned link → redirects to the invite form showing + *" invited you to Welfie"* and a read-only **Referred by** field +4. Fill referee details, submit → lands on the webapp signup with `?lt=` + +--- + +## 9. Inspecting state + +```bash +PSQL="docker exec refref-db psql -U postgres -d refref" + +# Products with an active program (what /join accepts) +$PSQL -c "SELECT p.id, p.name, g.id AS program_id FROM product p + JOIN program g ON g.product_id = p.id WHERE g.status='active';" + +# Anonymous referrers and their links +$PSQL -c "SELECT p.external_id, p.name, r.code + FROM participant p JOIN refcode r ON r.participant_id = p.id + WHERE p.external_id LIKE 'anon:%';" + +# Most recent captured lead +$PSQL -x -c "SELECT code, email, first_name, last_name, phone, status, utm + FROM referral_lead ORDER BY created_at DESC LIMIT 1;" + +# Webhook delivery attempts +$PSQL -c "SELECT event_type, last_status, attempts, max_attempts + FROM webhook_delivery ORDER BY created_at DESC LIMIT 10;" +``` + +Clean up test data: + +```bash +$PSQL -c "DELETE FROM participant WHERE external_id LIKE 'anon:%';" +``` + +--- + +## 10. Troubleshooting + +### `Corepack is about to download pnpm… Operation not permitted` + +Corepack needs network and a writable `~/.cache` on first run. Run +`corepack enable && pnpm --version` once in a normal terminal, or invoke the +local binary directly: `node node_modules/typescript/bin/tsc --noEmit`. + +### `unable to determine transport target for "pino-pretty"` + +`pino-pretty` is a devDependency, but Fastify enables it whenever +`NODE_ENV !== "production"`. The production image installs `--prod` only, so the +container crashes on boot. Set `NODE_ENV=production` for containerised runs — +already configured on the `refer` service in `docker-compose.yml`. + +### `pnpm build` fails on `@refref/webapp` — "Invalid environment variables" + +The webapp validates env at build time via `env.ts`. Populate +`apps/webapp/.env`, or build only what you need: + +```bash +pnpm exec turbo run build --filter=@refref/refer... +``` + +### Join page returns 404 + +The product must exist **and** have a program with `status = 'active'`: + +```bash +docker exec refref-db psql -U postgres -d refref -c \ + "SELECT product_id, status FROM program WHERE status='active';" +``` + +### Form submission rejected with "This email address can't be used" + +Self-referral detection: the referee entered the referrer's own email address. +Any other address is accepted, including one on the same domain. + +### Referral link 404s + +Codes are normalised to lowercase and must exist in `refcode`. `/:code` matches +7-char global codes; `/:productSlug/:code` resolves vanity slugs through +`reflink`. Static routes such as `/join` and `/health` take precedence over the +`/:code` parameter route. + +### Widget scripts fail with `ERR_CONNECTION_REFUSED` on :8787 + +`apps/assets` is not part of Docker Compose. Either start it +(`pnpm -F @refref/assets dev`) or ignore it — it does not affect the referral +redirect or form flows. + +### Changes to `apps/refer` are not visible + +The Docker image is built from source. Rebuild: + +```bash +docker compose build refer && docker compose up -d refer +``` + +### Ports already in use + +```bash +pnpm -F @refref/e2e cleanup-ports # frees 3000-3003 and 8787 +``` diff --git a/WELFIE_FORK_IMPLEMENTATION.md b/WELFIE_FORK_IMPLEMENTATION.md new file mode 100644 index 0000000..f04d022 --- /dev/null +++ b/WELFIE_FORK_IMPLEMENTATION.md @@ -0,0 +1,113 @@ +# Welfie referral fork — RefRef implementation notes + +This fork adds the two capabilities the plan identified as missing from upstream +`refrefhq/refref` — **the referee form** and **the outbound webhook** — plus the +supporting schema, hand-off token flow, qualified-signup reconciliation, and +abuse controls. All changes are **additive** (no upstream table or route was +modified in a breaking way), so the fork stays easy to rebase. + +Branch: `feat/welfie-referral-fork`. + +## What the repo actually had (Phase 0 findings) + +Several plan "open questions" were resolved by reading the code, not the docs: + +- **`participant.external_id` already exists** (unique per product). The + Welfie-user-id → participant mapping needs no new column. +- **A qualifying-event ingestion path already exists**: `POST /v1/track/signup` + (API-key auth) creates a `referral` + `event` and runs the reward engine. This + is the endpoint welfie-backend calls on Welfie signup. (Fallback of "write the + event directly to the DB" is unnecessary.) +- **`apps/refer` was a pure redirect service** (`/r/:code` → 307 to the + product's own landing page). There was no RefRef-hosted form. It is now the + home of the public invite page, which is the natural fit (it is already the + public, rate-limited, no-auth service). +- **No** webhook dispatcher, lead/consent storage, or hand-off tokens existed. +- The referrer-side share UI (widget) and JWT init already exist + (`/v1/widget/init`), so Phase 2 largely stands. + +## Schema (`packages/coredb`, migration `0003`) + +New, additive tables (+ entity-id prefixes in `@refref/id`): + +- `referral_lead` — a referee captured by the form **before** they have an + account (a new table, because `referral.external_id` is `NOT NULL` = the + referee's Welfie id, which doesn't exist yet at form time). Stores status, + `email_verified`, versioned consent, attribution (`utm`, `ip`, `user_agent`), + and `qualified_referral_id` / `matched_by` once reconciled. +- `handoff_token` — single-use, short-lived; stores only the SHA-256 **hash**. +- `webhook_endpoint` — per-product URL + `secret` + `secondary_secret` (rotation + overlap) + subscribed `event_types`. +- `webhook_delivery` — outbound log **and** dead-letter queue; `event_id` is the + idempotency key sent to the receiver, `dedupe_key` guards against + double-enqueue. The worker polls this table. + +Apply with `pnpm --filter @refref/coredb db:migrate` (or `db:push` in dev). + +## Webhook dispatcher (`packages/webhooks`, new) + +- **Signing**: HMAC-SHA256 over `${timestamp}.${rawBody}` → `X-Refref-Signature: + sha256=` plus `X-Refref-Timestamp`. Binding the timestamp into the signed + string makes replays detectable; the receiver also rejects skew > 120s. +- **`verifySignature`** accepts an **array of secrets** — the reference the + receiver mirrors, and what enables rotation with overlap. +- **`enqueueWebhook`** writes one delivery row per subscribed endpoint. Never + calls out inline (a slow downstream can't become a form timeout). +- **Worker** (`runWebhookWorker` / `runWebhookWorkerOnce`): claims due rows + atomically, POSTs with a timeout, on failure applies exponential backoff with + jitter, and dead-letters after `max_attempts`. `replayDelivery` re-queues a + dead-lettered row. +- Runs inside the API when `WEBHOOK_WORKER_ENABLED=true`, or standalone via + `apps/api` `pnpm --filter @refref/api worker` (`src/worker.ts`). + +## Referee form + hand-off (`apps/refer`) + +- `GET {basePath}/invite/:code` — resolves the code server-side and renders a + personalised, dependency-free HTML page. Fields are limited to **first name + + email** by policy, with a versioned consent checkbox, a honeypot, and optional + Turnstile. +- `POST {basePath}/invite/:code` — validates, runs abuse checks, writes the + lead, enqueues `referral.created`, issues a single-use hand-off token, and + `303`-redirects to `WELFIE_SIGNUP_URL?lt=`. +- `basePath` (`REFER_BASE_PATH`, e.g. `/refer`) matches the reverse-proxy mount. + `trustProxy` is on so client IP is correct behind the proxy. + +## Qualified + token exchange (`apps/api`) + +- `POST /v1/handoff/exchange` (API-key auth, **internal only** — allowlist to the + private network at the proxy): single-use, constant-time hash claim; returns + the referee prefill + referrer attribution. +- `POST /v1/track/signup` extended: after recording the event it calls + `reconcileSignup`, which matches a pending lead by (0) explicit `leadId`, (1) + hand-off token, or (2) **email within the attribution window** — the last one + covers "submitted on mobile, signed up days later on desktop". On a match the + lead flips to `qualified` and `referral.qualified` is enqueued. Reward status + is reported as `pending_approval` (manual approval in v1). Failures here never + fail the signup call. + +## Abuse controls + +Honeypot (silent drop), disposable-domain rejection, self-referral (referrer's +own email or shared **custom** domain — shared free providers like gmail are +allowed), tighter per-route rate limits, and optional Turnstile. Per the plan, +per-IP/per-code rate limits + WAF also belong at the proxy. + +## Tests + +- `packages/webhooks` — signing round-trip, tamper, skew, rotation overlap, + backoff (8). +- `apps/refer` — abuse helpers (9) + existing redirect/health (19). +- `apps/api` — existing suite unaffected (53). +- **End-to-end** against a real Postgres: `apps/refer/scripts/itest.mts` + (`pnpm --filter @refref/refer itest`) — 38 assertions covering page render, + form submit, lead creation, `referral.created` enqueue + **retry-then-deliver** + with signature verification, idempotent double-submit, single-use hand-off, + email-reconciliation qualify + `referral.qualified`, and every abuse path. + +## Still owned by welfie-backend (not in this fork) + +Receiving the webhooks (verify HMAC + timestamp before parsing, upsert on +`event_id`), the HubSpot writes from a separate queue, double-opt-in email +verification before promoting a lead to a HubSpot contact, retention/deletion, +and the reward-approval UI. `verifySignature` in `@refref/webhooks` is the +reference to mirror on the receiver. diff --git a/apps/api/.env.example b/apps/api/.env.example index db2262d..cc3f2ca 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -10,3 +10,15 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/refref" # Referral Configuration # With portless: http://refref-refer.localhost:1355 | Without: http://localhost:3002 REFERRAL_HOST_URL="http://refref-refer.localhost:1355" + +# --- Welfie referral fork additions --- + +# Outbound webhook dispatcher. Set to "true" to run the worker inside the API +# process; leave unset and run `pnpm --filter @refref/api worker` as its own +# process in production. +WEBHOOK_WORKER_ENABLED="false" +# Poll interval for the webhook worker (ms). +WEBHOOK_WORKER_INTERVAL_MS=5000 + +# Email-reconciliation attribution window for late/cross-device signups (days). +ATTRIBUTION_WINDOW_DAYS=30 diff --git a/apps/api/package.json b/apps/api/package.json index 6a004f6..8377f5d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "dev": "portless refref-api -- infisical run --env=dev -- tsx watch src/index.ts || portless refref-api -- tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", + "worker": "node dist/worker.js", "test": "infisical run --env=dev -- vitest run || vitest run", "test:watch": "infisical run --env=dev -- vitest || vitest", "test:ui": "infisical run --env=dev -- vitest --ui || vitest --ui", @@ -24,6 +25,7 @@ "@refref/id": "workspace:*", "@refref/types": "workspace:*", "@refref/utils": "workspace:*", + "@refref/webhooks": "workspace:*", "@tsndr/cloudflare-worker-jwt": "^3.2.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 58b73c0..0f0f4e5 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,6 +9,8 @@ import jwtAuthPlugin from "./plugins/jwt-auth.js"; import widgetInitRoutes from "./routes/v1/widget/init.js"; import trackRoutes from "./routes/v1/track.js"; import programsRoutes from "./routes/v1/programs.js"; +import handoffRoutes from "./routes/v1/handoff.js"; +import { runWebhookWorker } from "@refref/webhooks"; export async function buildApp(): Promise { // Validate required environment variables @@ -66,9 +68,26 @@ export async function buildApp(): Promise { // Programs routes await fastify.register(programsRoutes, { prefix: "/programs" }); + + // Internal hand-off token exchange (API-key auth; keep off public ingress) + await fastify.register(handoffRoutes, { prefix: "/handoff" }); }, { prefix: "/v1" }, ); + // Optionally run the outbound webhook dispatcher inside the API process. + // In production you may prefer the standalone worker (see src/worker.ts). + if (process.env.WEBHOOK_WORKER_ENABLED === "true") { + const intervalMs = Number(process.env.WEBHOOK_WORKER_INTERVAL_MS) || 5000; + const stop = runWebhookWorker(db, intervalMs, { + logger: { + info: (o, m) => app.log.info(o, m), + error: (o, m) => app.log.error(o, m), + }, + }); + app.addHook("onClose", async () => stop()); + app.log.info(`Webhook worker started (interval ${intervalMs}ms)`); + } + return app; } diff --git a/apps/api/src/routes/v1/handoff.ts b/apps/api/src/routes/v1/handoff.ts new file mode 100644 index 0000000..1dd75fe --- /dev/null +++ b/apps/api/src/routes/v1/handoff.ts @@ -0,0 +1,74 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { z } from "zod"; +import { schema } from "@refref/coredb"; +import { eq } from "drizzle-orm"; +import { consumeHandoffToken } from "../../services/referral-lead.js"; + +const { participant } = schema; + +const exchangeSchema = z.object({ + token: z.string().min(1), +}); + +/** + * Internal-only hand-off token exchange. welfie-backend calls this server-side + * to turn the single-use `lt` token from the redirect into the referee's + * prefill data. It is guarded by the same API-key auth as the track endpoints + * and MUST NOT be exposed to public ingress (allowlist it to the private + * network at the proxy). Single-use: a second exchange of the same token 404s. + */ +export default async function handoffRoutes(fastify: FastifyInstance) { + fastify.post( + "/exchange", + { preHandler: [fastify.authenticateApiKey] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const parsed = exchangeSchema.safeParse(request.body); + if (!parsed.success) { + return reply + .code(400) + .send({ success: false, message: "A token is required." }); + } + + const lead = await consumeHandoffToken(request.db, parsed.data.token); + if (!lead) { + // Unknown, expired, or already consumed — do not distinguish (no oracle). + return reply + .code(404) + .send({ success: false, message: "Invalid or expired token." }); + } + + // Fetch the referrer for attribution prefill. + const [referrer] = await request.db + .select({ + id: participant.id, + externalId: participant.externalId, + name: participant.name, + email: participant.email, + }) + .from(participant) + .where(eq(participant.id, lead.referrerParticipantId)) + .limit(1); + + return reply.send({ + success: true, + lead: { + id: lead.id, + email: lead.email, + firstName: lead.firstName, + code: lead.code, + programId: lead.programId, + productId: lead.productId, + consentVersion: lead.consentVersion, + consentAt: lead.consentAt, + }, + referrer: referrer + ? { + participantId: referrer.id, + externalId: referrer.externalId, + name: referrer.name, + } + : null, + }); + }, + ); +} diff --git a/apps/api/src/routes/v1/track/signup.ts b/apps/api/src/routes/v1/track/signup.ts index c591a02..3cc0ff6 100644 --- a/apps/api/src/routes/v1/track/signup.ts +++ b/apps/api/src/routes/v1/track/signup.ts @@ -1,11 +1,46 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { z } from "zod"; import { schema } from "@refref/coredb"; -const { participant, referral, refcode } = schema; +const { participant, referral, refcode, rewardRule } = schema; import { eq, and } from "drizzle-orm"; -import { type EventMetadataV1Type } from "@refref/types"; +import { + type EventMetadataV1Type, + type RewardRuleConfigV1Type, + type ReferralQualifiedPayload, +} from "@refref/types"; import { createEvent } from "../../../services/events.js"; import { normalizeCode } from "@refref/utils"; +import { enqueueWebhook } from "@refref/webhooks"; +import { reconcileSignup } from "../../../services/referral-lead.js"; + +/** + * Derive the referrer reward summary for the qualified webhook. Reward release + * is gated on manual approval in v1, so status is always reported as + * "pending_approval" regardless of the rule's disbursal type. + */ +async function deriveRewardSummary( + db: FastifyRequest["db"], + programId: string, +): Promise { + const rules = await db + .select() + .from(rewardRule) + .where( + and(eq(rewardRule.programId, programId), eq(rewardRule.isActive, true)), + ); + const referrerRule = rules.find( + (r) => (r.config as RewardRuleConfigV1Type)?.participantType === "referrer", + ); + if (!referrerRule) return null; + const c = referrerRule.config as RewardRuleConfigV1Type; + return { + id: null, + type: c.reward.type, + amount: Number(c.reward.amount) || 0, + currency: c.reward.currency || "AUD", + status: "pending_approval", + }; +} // Signup event request schema (no eventType discriminator needed) const signupRequestSchema = z.object({ @@ -17,6 +52,9 @@ const signupRequestSchema = z.object({ refcode: z.string().optional(), email: z.string().email().optional(), name: z.string().optional(), + // Optional deterministic hand-off matching (from the referee form flow). + handoffToken: z.string().optional(), + leadId: z.string().optional(), }), }); @@ -119,6 +157,59 @@ export default async function signupTrackRoutes(fastify: FastifyInstance) { metadata: result.metadata, }); + // Reconcile any pending referee lead into "qualified" and emit the + // referral.qualified webhook. Best-effort: a hiccup here must never fail + // the signup tracking itself (the event is already recorded). + try { + const qualified = await reconcileSignup(request.db, { + productId: body.productId, + email: body.payload.email, + leadId: body.payload.leadId, + handoffToken: body.payload.handoffToken, + referralId: result.referralId, + refereeExternalId: body.payload.userId, + windowDays: Number(process.env.ATTRIBUTION_WINDOW_DAYS) || 30, + }); + + if (qualified) { + const reward = await deriveRewardSummary( + request.db, + qualified.programId, + ); + const qualifiedPayload: Omit< + ReferralQualifiedPayload, + "event_id" + > = { + event: "referral.qualified", + occurred_at: new Date().toISOString(), + program_id: qualified.programId, + referral: { + id: result.referralId ?? qualified.leadId, + code: qualified.code, + status: "qualified", + }, + referee: { + email: qualified.email, + external_id: body.payload.userId, + }, + reward, + matched_by: qualified.matchedBy, + }; + + await enqueueWebhook(request.db, { + productId: body.productId, + eventType: "referral.qualified", + payload: qualifiedPayload as unknown as Record, + dedupeKey: `qualified:${qualified.leadId}`, + }); + } + } catch (err) { + request.log.error( + { err }, + "referral qualification reconcile failed", + ); + } + return reply.send({ success: true, message: "Signup tracked successfully.", diff --git a/apps/api/src/services/referral-lead.ts b/apps/api/src/services/referral-lead.ts new file mode 100644 index 0000000..8793ab9 --- /dev/null +++ b/apps/api/src/services/referral-lead.ts @@ -0,0 +1,220 @@ +import { createHash } from "node:crypto"; +import { schema } from "@refref/coredb"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { and, desc, eq, gte, isNull } from "drizzle-orm"; + +const { referralLead, handoffToken } = schema; + +type DbType = PostgresJsDatabase; + +export type MatchedBy = "handoff_token" | "email_reconciliation"; + +export interface QualifiedLead { + leadId: string; + matchedBy: MatchedBy; + code: string; + programId: string; + email: string; + referrerParticipantId: string; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +/** + * Consume a hand-off token: atomically mark it consumed only if it is unused and + * unexpired (single-use, constant-time compare on the stored hash), returning + * the associated lead. Returns null if the token is unknown, expired, or already + * consumed. + */ +export async function consumeHandoffToken(db: DbType, token: string, now = new Date()) { + const tokenHash = hashToken(token); + + // Atomic single-use claim. The WHERE clause guarantees only the first caller + // wins even under concurrent exchange attempts. + const [claimed] = await db + .update(handoffToken) + .set({ consumedAt: now, updatedAt: now }) + .where( + and( + eq(handoffToken.tokenHash, tokenHash), + isNull(handoffToken.consumedAt), + gte(handoffToken.expiresAt, now), + ), + ) + .returning(); + + if (!claimed) return null; + + const [lead] = await db + .select() + .from(referralLead) + .where(eq(referralLead.id, claimed.leadId)) + .limit(1); + + return lead ?? null; +} + +/** + * Look up a hand-off token WITHOUT consuming it (for the exchange endpoint that + * prefills the signup form). Returns the lead if valid and unconsumed. + */ +export async function peekHandoffToken(db: DbType, token: string, now = new Date()) { + const tokenHash = hashToken(token); + const [row] = await db + .select() + .from(handoffToken) + .where(eq(handoffToken.tokenHash, tokenHash)) + .limit(1); + if (!row || row.consumedAt || row.expiresAt < now) return null; + const [lead] = await db + .select() + .from(referralLead) + .where(eq(referralLead.id, row.leadId)) + .limit(1); + return lead ?? null; +} + +/** Mark a lead qualified and link it to the real referral row. Idempotent. */ +async function markQualified( + db: DbType, + leadId: string, + opts: { + matchedBy: MatchedBy; + referralId?: string; + refereeExternalId?: string; + now?: Date; + }, +) { + const now = opts.now ?? new Date(); + const [updated] = await db + .update(referralLead) + .set({ + status: "qualified", + matchedBy: opts.matchedBy, + qualifiedReferralId: opts.referralId ?? null, + qualifiedAt: now, + emailVerified: true, + updatedAt: now, + }) + // Only transition from pending -> qualified so re-delivery is harmless. + .where(and(eq(referralLead.id, leadId), eq(referralLead.status, "pending"))) + .returning(); + return updated ?? null; +} + +export interface ReconcileInput { + productId: string; + email?: string; + /** Lead id from a prior token exchange, for deterministic matching. */ + leadId?: string; + /** Explicit token passed by welfie-backend for deterministic matching. */ + handoffToken?: string; + /** The referral row id created for this signup, to link back. */ + referralId?: string; + /** The new referee's Welfie user id. */ + refereeExternalId?: string; + /** Email-reconciliation attribution window in days. */ + windowDays?: number; + now?: Date; +} + +/** + * Reconcile a signup against a pending lead. Prefers the hand-off token (exact + * match) and falls back to email reconciliation within the attribution window — + * the latter is what covers "submitted on mobile, signed up days later on + * desktop". Returns the qualified lead, or null if nothing matched. + */ +export async function reconcileSignup( + db: DbType, + input: ReconcileInput, +): Promise { + const now = input.now ?? new Date(); + const windowDays = input.windowDays ?? 30; + + // 0) Direct lead id (welfie-backend already exchanged the token server-side). + if (input.leadId) { + const [lead] = await db + .select() + .from(referralLead) + .where(eq(referralLead.id, input.leadId)) + .limit(1); + if (lead && lead.status === "pending" && lead.productId === input.productId) { + const updated = await markQualified(db, lead.id, { + matchedBy: "handoff_token", + referralId: input.referralId, + refereeExternalId: input.refereeExternalId, + now, + }); + if (updated) { + return { + leadId: updated.id, + matchedBy: "handoff_token", + code: updated.code, + programId: updated.programId, + email: updated.email, + referrerParticipantId: updated.referrerParticipantId, + }; + } + } + } + + // 1) Deterministic hand-off token match. + if (input.handoffToken) { + const lead = await consumeHandoffToken(db, input.handoffToken, now); + if (lead && lead.status === "pending") { + const updated = await markQualified(db, lead.id, { + matchedBy: "handoff_token", + referralId: input.referralId, + refereeExternalId: input.refereeExternalId, + now, + }); + if (updated) { + return { + leadId: updated.id, + matchedBy: "handoff_token", + code: updated.code, + programId: updated.programId, + email: updated.email, + referrerParticipantId: updated.referrerParticipantId, + }; + } + } + } + + // 2) Email reconciliation within the attribution window. + if (input.email) { + const email = input.email.trim().toLowerCase(); + const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const lead = await db.query.referralLead.findFirst({ + where: and( + eq(referralLead.productId, input.productId), + eq(referralLead.email, email), + eq(referralLead.status, "pending"), + gte(referralLead.createdAt, cutoff), + ), + orderBy: desc(referralLead.createdAt), + }); + if (lead) { + const updated = await markQualified(db, lead.id, { + matchedBy: "email_reconciliation", + referralId: input.referralId, + refereeExternalId: input.refereeExternalId, + now, + }); + if (updated) { + return { + leadId: updated.id, + matchedBy: "email_reconciliation", + code: updated.code, + programId: updated.programId, + email: updated.email, + referrerParticipantId: updated.referrerParticipantId, + }; + } + } + } + + return null; +} diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts new file mode 100644 index 0000000..0a68d0e --- /dev/null +++ b/apps/api/src/worker.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; +import { createDb } from "@refref/coredb"; +import { runWebhookWorker } from "@refref/webhooks"; + +/** + * Standalone outbound webhook worker. Run this as its own process/container when + * you don't want the dispatcher sharing the API's event loop: + * + * node dist/worker.js + */ +const start = () => { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL environment variable is required"); + process.exit(1); + } + + const db = createDb(databaseUrl); + const intervalMs = Number(process.env.WEBHOOK_WORKER_INTERVAL_MS) || 5000; + + const stop = runWebhookWorker(db, intervalMs, { + logger: { + info: (o, m) => console.log(m ?? "", o), + error: (o, m) => console.error(m ?? "", o), + }, + }); + + console.log(`Webhook worker running (interval ${intervalMs}ms)`); + + const signals = ["SIGINT", "SIGTERM"] as const; + for (const signal of signals) { + process.on(signal, () => { + console.log(`Received ${signal}, stopping worker...`); + stop(); + process.exit(0); + }); + } +}; + +start(); diff --git a/apps/refer/.env.example b/apps/refer/.env.example index de2182f..847bcfe 100644 --- a/apps/refer/.env.example +++ b/apps/refer/.env.example @@ -17,3 +17,33 @@ HOST="0.0.0.0" # Log level: trace, debug, info, warn, error, fatal (default: info) LOG_LEVEL="info" + +# --- Welfie referral fork additions --- + +# Path prefix the app is mounted under at the reverse proxy (e.g. "/refer"). +# Leave blank to serve /invite/:code at the root. +REFER_BASE_PATH="/refer" + +# Public origin that serves the /:code redirects, used to build referral links +# handed out by /join. This is this app's own origin and must NOT include +# REFER_BASE_PATH, because /:code is registered at the root. +# With portless: http://refref-refer.localhost:1355 | Without: http://localhost:3002 +REFERRAL_HOST_URL="http://refref-refer.localhost:1355" + +# Where the referee is redirected after submitting the form. +WELFIE_SIGNUP_URL="https://welfie.com/signup" + +# Consent copy version stored with every lead (bump when the wording changes). +REFERRAL_CONSENT_VERSION="privacy-2026-08" +# Link shown beside the consent checkbox. +PRIVACY_POLICY_URL="https://welfie.com/privacy" + +# Single-use hand-off token TTL (minutes). +HANDOFF_TTL_MINUTES=10 + +# Extra disposable email domains to reject (comma/space separated). +DISPOSABLE_DOMAINS_EXTRA="" + +# Optional Cloudflare Turnstile (CAPTCHA). If a secret is set it is enforced. +TURNSTILE_SITE_KEY="" +TURNSTILE_SECRET="" diff --git a/apps/refer/package.json b/apps/refer/package.json index 7deca9c..9ee0168 100644 --- a/apps/refer/package.json +++ b/apps/refer/package.json @@ -12,20 +12,24 @@ "test:ui": "infisical run --env=dev -- vitest --ui || vitest --ui", "test:run": "infisical run --env=dev -- vitest run || vitest run", "type:check": "tsc --noEmit", + "itest": "tsx scripts/itest.mts", "lint": "eslint .", "format": "prettier --write .", "fly:deploy": "cd ../../ && flyctl deploy --app refref-refer --dockerfile Dockerfile.refer --config apps/refer/fly.toml --build-arg GIT_SHA=$(git rev-parse --short HEAD) --env GIT_SHA=$(git rev-parse --short HEAD)" }, "dependencies": { "@fastify/cors": "^10.0.1", + "@fastify/formbody": "^8.0.0", "@fastify/rate-limit": "^10.3.0", "@refref/coredb": "workspace:*", "@refref/types": "workspace:*", "@refref/utils": "workspace:*", + "@refref/webhooks": "workspace:*", "dotenv": "^17.2.3", "drizzle-orm": "^0.44.7", "fastify": "^5.2.0", - "postgres": "^3.4.5" + "postgres": "^3.4.5", + "zod": "^4.0.17" }, "devDependencies": { "@types/node": "^22.10.2", diff --git a/apps/refer/scripts/itest.mts b/apps/refer/scripts/itest.mts new file mode 100644 index 0000000..5f2d8a5 --- /dev/null +++ b/apps/refer/scripts/itest.mts @@ -0,0 +1,319 @@ +/** + * End-to-end integration test for the Welfie referral fork. + * Runs against a real Postgres (seeded). Boots the refer app, drives the full + * referee-form -> lead -> webhook -> handoff -> qualified flow, and verifies + * HMAC signatures on a local receiver. Exits non-zero on any failed assertion. + */ +import http from "node:http"; +import { createDb, schema } from "@refref/coredb"; +import { + enqueueWebhook, + runWebhookWorkerOnce, + verifySignature, +} from "@refref/webhooks"; +import { and, eq } from "drizzle-orm"; +import { + consumeHandoffToken, + reconcileSignup, +} from "../../api/src/services/referral-lead.js"; +import { buildApp } from "../src/app.js"; + +const { webhookEndpoint, referralLead, webhookDelivery, participant } = schema; + +const PRODUCT_1 = "prd_rfl78apntmdzwuyxykqd7ait"; +const CODE = "rpetnw5"; +const SECRET = "whsec_itest_primary"; +const SIGNUP_URL = "https://welfie.example/signup"; + +let failures = 0; +function check(name: string, cond: boolean, extra?: unknown) { + if (cond) { + console.log(` ✓ ${name}`); + } else { + failures++; + console.error(` ✗ ${name}`, extra ?? ""); + } +} + +const db = createDb(process.env.DATABASE_URL!); + +async function main() { + // --- local webhook receiver ------------------------------------------------- + const received: Array<{ type: string; body: any; sigValid: boolean }> = []; + let failFirst = true; // force one retry to exercise backoff/dead-letter path + const receiver = http.createServer((req, res) => { + let raw = ""; + req.on("data", (c) => (raw += c)); + req.on("end", () => { + const sig = req.headers["x-refref-signature"] as string; + const ts = req.headers["x-refref-timestamp"] as string; + const result = verifySignature({ + secrets: [SECRET], + signature: sig, + timestamp: ts, + rawBody: raw, + }); + const body = JSON.parse(raw); + if (failFirst) { + failFirst = false; + res.statusCode = 503; + res.end("try later"); + return; + } + received.push({ type: body.event, body, sigValid: result.valid }); + res.statusCode = 200; + res.end("ok"); + }); + }); + await new Promise((r) => receiver.listen(0, "127.0.0.1", r)); + const recvPort = (receiver.address() as any).port; + const recvUrl = `http://127.0.0.1:${recvPort}/hook`; + + // Register endpoint for the product. + await db.delete(webhookEndpoint).where(eq(webhookEndpoint.productId, PRODUCT_1)); + await db.insert(webhookEndpoint).values({ + productId: PRODUCT_1, + url: recvUrl, + secret: SECRET, + }); + + // Clean any prior test leads/deliveries for a deterministic run. + await db.delete(referralLead); + await db.delete(webhookDelivery); + + // --- boot the refer app ----------------------------------------------------- + process.env.WELFIE_SIGNUP_URL = SIGNUP_URL; + process.env.REFER_BASE_PATH = ""; + const app = await buildApp(); + await app.listen({ port: 0, host: "127.0.0.1" }); + const addr = app.server.address() as any; + const base = `http://127.0.0.1:${addr.port}`; + + const worker = () => + runWebhookWorkerOnce(db, { baseBackoffMs: 1, requestTimeoutMs: 3000 }); + + try { + console.log("\n[1] GET invite page"); + const pageRes = await fetch(`${base}/invite/${CODE}`); + const pageHtml = await pageRes.text(); + check("returns 200", pageRes.status === 200); + check("renders the form", pageHtml.includes("Continue to Welfie")); + check("has honeypot field", pageHtml.includes('name="company"')); + check("has consent checkbox", pageHtml.includes('name="consent"')); + + console.log("\n[2] POST valid referee form"); + const form = new URLSearchParams({ + email: "Friend@Example.com", + firstName: "Friendly", + consent: "yes", + consent_version: "privacy-2026-08", + }); + const submit = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: form, + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("returns 303 redirect", submit.status === 303); + const loc = submit.headers.get("location") || ""; + check("redirects to signup with token", loc.startsWith(`${SIGNUP_URL}?lt=`), loc); + const token = new URL(loc).searchParams.get("lt") || ""; + check("token is present", token.length > 20); + + const leads = await db + .select() + .from(referralLead) + .where(eq(referralLead.email, "friend@example.com")); + check("lead created (email normalized)", leads.length === 1); + check("lead is pending", leads[0]?.status === "pending"); + check("lead email not verified", leads[0]?.emailVerified === false); + check("consent version stored", leads[0]?.consentVersion === "privacy-2026-08"); + check("consent timestamp stored", !!leads[0]?.consentAt); + check("ip captured", !!leads[0]?.ip); + const leadId = leads[0]!.id; + + const createdDeliveries = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.eventType, "referral.created")); + check("referral.created enqueued", createdDeliveries.length === 1); + check( + "payload event correct", + (createdDeliveries[0]?.payload as any)?.event === "referral.created", + ); + check( + "payload has event_id", + typeof (createdDeliveries[0]?.payload as any)?.event_id === "string", + ); + + console.log("\n[3] Webhook worker: retry then deliver"); + await worker(); // first attempt -> receiver returns 503 + const afterFirst = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.id, createdDeliveries[0]!.id)); + check("first attempt failed", afterFirst[0]?.lastStatus === "failed"); + check("attempts incremented", afterFirst[0]?.attempts === 1); + await new Promise((r) => setTimeout(r, 60)); + await worker(); // second attempt -> 200 + const afterSecond = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.id, createdDeliveries[0]!.id)); + check("delivered on retry", afterSecond[0]?.lastStatus === "delivered"); + check( + "receiver verified signature", + received.some((r) => r.type === "referral.created" && r.sigValid), + ); + + console.log("\n[4] Idempotent double submit"); + const submit2 = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: new URLSearchParams({ + email: "friend@example.com", + firstName: "Friendly", + consent: "yes", + }), + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("second submit also 303", submit2.status === 303); + const leadsAfter = await db + .select() + .from(referralLead) + .where(eq(referralLead.email, "friend@example.com")); + check("no duplicate lead", leadsAfter.length === 1); + const createdAfter = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.eventType, "referral.created")); + check("no duplicate created-delivery (dedupe)", createdAfter.length === 1); + + console.log("\n[5] Hand-off token single-use"); + const first = await consumeHandoffToken(db, token); + check("first exchange resolves lead", first?.id === leadId); + const second = await consumeHandoffToken(db, token); + check("second exchange is rejected (single-use)", second === null); + + console.log("\n[6] Qualify via email reconciliation + referral.qualified"); + const qualified = await reconcileSignup(db, { + productId: PRODUCT_1, + email: "friend@example.com", + refereeExternalId: "usr_newfriend", + windowDays: 30, + }); + check("reconcile matched by email", qualified?.matchedBy === "email_reconciliation"); + const qLead = await db + .select() + .from(referralLead) + .where(eq(referralLead.id, leadId)); + check("lead now qualified", qLead[0]?.status === "qualified"); + check("lead email now verified", qLead[0]?.emailVerified === true); + + await enqueueWebhook(db, { + productId: PRODUCT_1, + eventType: "referral.qualified", + payload: { + event: "referral.qualified", + occurred_at: new Date().toISOString(), + program_id: qualified!.programId, + referral: { id: leadId, code: CODE, status: "qualified" }, + referee: { email: "friend@example.com", external_id: "usr_newfriend" }, + reward: { + id: null, + type: "cash", + amount: 10, + currency: "AUD", + status: "pending_approval", + }, + matched_by: qualified!.matchedBy, + }, + dedupeKey: `qualified:${leadId}`, + }); + await new Promise((r) => setTimeout(r, 20)); + await worker(); + const qDelivery = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.eventType, "referral.qualified")); + check("referral.qualified delivered", qDelivery[0]?.lastStatus === "delivered"); + check( + "receiver got qualified w/ matched_by + pending_approval reward", + received.some( + (r) => + r.type === "referral.qualified" && + r.sigValid && + r.body.matched_by === "email_reconciliation" && + r.body.reward.status === "pending_approval", + ), + ); + + console.log("\n[7] Abuse controls"); + // disposable email + const disp = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: new URLSearchParams({ email: "x@mailinator.com", consent: "yes" }), + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("disposable email rejected (400)", disp.status === 400); + check("disposable message shown", (await disp.text()).includes("disposable")); + + // honeypot filled -> silent redirect, no lead + const hp = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: new URLSearchParams({ + email: "bot@example.com", + consent: "yes", + company: "AcmeBot", + }), + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("honeypot -> 303", hp.status === 303); + check("honeypot -> no token", !(hp.headers.get("location") || "").includes("lt=")); + const botLeads = await db + .select() + .from(referralLead) + .where(eq(referralLead.email, "bot@example.com")); + check("honeypot -> no lead persisted", botLeads.length === 0); + + // self-referral (referrer's own email) + const [ref] = await db + .select() + .from(participant) + .where(eq(participant.id, "prt_na69t6s091jfb1dpqy1c48rv")); + const self = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: new URLSearchParams({ email: ref!.email!, consent: "yes" }), + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("self-referral rejected (400)", self.status === 400); + + // missing consent + const noConsent = await fetch(`${base}/invite/${CODE}`, { + method: "POST", + body: new URLSearchParams({ email: "someone@example.com" }), + redirect: "manual", + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + check("missing consent rejected (400)", noConsent.status === 400); + + // unknown code + const unknown = await fetch(`${base}/invite/zzzzzzz`); + check("unknown code -> 404", unknown.status === 404); + } finally { + await app.close(); + receiver.close(); + } + + console.log(`\n${failures === 0 ? "ALL PASSED" : failures + " FAILED"}`); + await db.$client.end?.(); + process.exit(failures === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/refer/src/app.ts b/apps/refer/src/app.ts index 40b7afc..8f2d9f6 100644 --- a/apps/refer/src/app.ts +++ b/apps/refer/src/app.ts @@ -1,10 +1,14 @@ import Fastify, { FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; +import formbody from "@fastify/formbody"; import { coredbPlugin } from "@refref/utils"; import { createDb } from "@refref/coredb"; import healthRoutes from "./routes/health.js"; import referralRedirectRoutes from "./routes/r.js"; +import inviteRoutes from "./routes/invite.js"; +import joinRoutes from "./routes/join.js"; +import { loadReferConfig } from "./lib/config.js"; export async function buildApp(): Promise { // Validate required environment variables @@ -17,6 +21,9 @@ export async function buildApp(): Promise { const db = createDb(databaseUrl); const app = Fastify({ + // The app always runs behind the reverse proxy, so trust X-Forwarded-* to + // get the real client IP for rate limiting and attribution. + trustProxy: true, logger: { level: process.env.LOG_LEVEL || "info", transport: @@ -33,6 +40,8 @@ export async function buildApp(): Promise { }, }); + const referConfig = loadReferConfig(); + // Register CORS plugin with permissive settings for public endpoints await app.register(cors, { origin: true, @@ -54,14 +63,25 @@ export async function buildApp(): Promise { }, }); + // Parse application/x-www-form-urlencoded bodies (the referee form posts these) + await app.register(formbody); + // Register coredb plugin with database instance await app.register(coredbPlugin, { db }); // Register health check routes await app.register(healthRoutes); - // Register referral redirect routes (/:id) - await app.register(referralRedirectRoutes); + // Register referral redirect routes (/:code -> invite form) + await app.register(referralRedirectRoutes(referConfig)); + + // Register the public invite page + form under the configured base path + // (e.g. "/refer"), matching how it is exposed at the reverse proxy. + await app.register(inviteRoutes(referConfig), { prefix: referConfig.basePath }); + + // Register the public self-serve page where an unauthenticated visitor can + // claim their own referral link. + await app.register(joinRoutes(referConfig), { prefix: referConfig.basePath }); return app; } diff --git a/apps/refer/src/lib/abuse.ts b/apps/refer/src/lib/abuse.ts new file mode 100644 index 0000000..8f88e9c --- /dev/null +++ b/apps/refer/src/lib/abuse.ts @@ -0,0 +1,90 @@ +/** + * Abuse / fraud controls for the public referee form. + * + * The invite form is genuinely public: anyone holding a valid code can POST it. + * These are the application-layer checks. Per the implementation plan, per-IP + * and per-code RATE LIMITS plus WAF/CAPTCHA live at the reverse proxy; this + * module covers what only the application can know (self-referral, disposable + * domains, honeypot). + */ + +/** A compact, curated disposable/temporary-email domain blocklist. */ +export const DISPOSABLE_DOMAINS = new Set([ + "mailinator.com", + "guerrillamail.com", + "guerrillamail.info", + "10minutemail.com", + "10minutemail.net", + "tempmail.com", + "temp-mail.org", + "throwawaymail.com", + "yopmail.com", + "getnada.com", + "trashmail.com", + "sharklasers.com", + "dispostable.com", + "maildrop.cc", + "fakeinbox.com", + "mailnesia.com", + "mohmal.com", + "mintemail.com", + "spam4.me", + "emailondeck.com", + "tempinbox.com", + "discard.email", + "byom.de", +]); + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function emailDomain(email: string): string | null { + const at = email.lastIndexOf("@"); + if (at <= 0 || at === email.length - 1) return null; + return email.slice(at + 1).toLowerCase(); +} + +/** Basic shape check; the real validator is zod at the route, this is a guard. */ +export function looksLikeEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + +export function isDisposableEmail( + email: string, + extraDomains: ReadonlySet = new Set(), +): boolean { + const domain = emailDomain(email); + if (!domain) return false; + return DISPOSABLE_DOMAINS.has(domain) || extraDomains.has(domain); +} + +export type SelfReferralReason = "same_email" | null; + +/** + * Detect obvious self-referral: the referee using the referrer's own address. + * Colleagues on a shared company domain are legitimate referrals, so only an + * exact address match is blocked. IP-based self-referral is handled at the proxy. + */ +export function selfReferralReason( + refereeEmail: string, + referrer: { email?: string | null }, +): SelfReferralReason { + if (!referrer.email) return null; + const referee = normalizeEmail(refereeEmail); + const ref = normalizeEmail(referrer.email); + return referee === ref ? "same_email" : null; +} + +/** Parse a comma/space separated env list of extra disposable domains. */ +export function parseExtraDisposableDomains( + raw: string | undefined, +): Set { + if (!raw) return new Set(); + return new Set( + raw + .split(/[\s,]+/) + .map((d) => d.trim().toLowerCase()) + .filter(Boolean), + ); +} diff --git a/apps/refer/src/lib/config.ts b/apps/refer/src/lib/config.ts new file mode 100644 index 0000000..850a139 --- /dev/null +++ b/apps/refer/src/lib/config.ts @@ -0,0 +1,85 @@ +import { parseExtraDisposableDomains } from "./abuse.js"; + +/** + * Runtime configuration for the refer app, read once from the environment. + * Kept in one place so the reverse-proxy / infra contract is explicit. + */ +export interface ReferConfig { + /** Path prefix the app is mounted under at the proxy, e.g. "/refer". */ + basePath: string; + /** Public origin that serves /:code redirects, used to build referral links. */ + referralHostUrl: string; + /** Where the friend is sent after submitting, e.g. https://welfie.com/signup */ + signupUrl: string; + /** Consent copy version stored with each lead. */ + consentVersion: string; + /** Link shown next to the consent checkbox. */ + privacyPolicyUrl: string; + /** Hand-off token TTL in minutes. */ + handoffTtlMinutes: number; + /** Extra disposable domains to reject, on top of the built-in list. */ + extraDisposableDomains: Set; + /** Optional Cloudflare Turnstile site key (renders the widget if set). */ + turnstileSiteKey?: string; + /** Optional Cloudflare Turnstile secret (server-side verification if set). */ + turnstileSecret?: string; +} + +function normalizeBasePath(raw: string | undefined): string { + if (!raw) return ""; + let p = raw.trim(); + if (p === "/" || p === "") return ""; + if (!p.startsWith("/")) p = `/${p}`; + if (p.endsWith("/")) p = p.slice(0, -1); + return p; +} + +function stripTrailingSlash(raw: string): string { + const v = raw.trim(); + return v.endsWith("/") ? v.slice(0, -1) : v; +} + +export function loadReferConfig(env: NodeJS.ProcessEnv = process.env): ReferConfig { + return { + basePath: normalizeBasePath(env.REFER_BASE_PATH), + referralHostUrl: stripTrailingSlash( + env.REFERRAL_HOST_URL || "http://localhost:3002", + ), + signupUrl: env.WELFIE_SIGNUP_URL || "https://welfie.com/signup", + consentVersion: env.REFERRAL_CONSENT_VERSION || "privacy-2026-08", + privacyPolicyUrl: + env.PRIVACY_POLICY_URL || "https://welfie.com/privacy", + handoffTtlMinutes: Number(env.HANDOFF_TTL_MINUTES) || 10, + extraDisposableDomains: parseExtraDisposableDomains( + env.DISPOSABLE_DOMAINS_EXTRA, + ), + turnstileSiteKey: env.TURNSTILE_SITE_KEY || undefined, + turnstileSecret: env.TURNSTILE_SECRET || undefined, + }; +} + +/** Server-side Cloudflare Turnstile verification. No-op pass if not configured. */ +export async function verifyTurnstile( + config: ReferConfig, + token: string | undefined, + remoteIp?: string, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!config.turnstileSecret) return true; // not enforced + if (!token) return false; + try { + const body = new URLSearchParams({ + secret: config.turnstileSecret, + response: token, + }); + if (remoteIp) body.set("remoteip", remoteIp); + const res = await fetchImpl( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { method: "POST", body }, + ); + const data = (await res.json()) as { success?: boolean }; + return data.success === true; + } catch { + return false; + } +} diff --git a/apps/refer/src/lib/handoff.ts b/apps/refer/src/lib/handoff.ts new file mode 100644 index 0000000..0327409 --- /dev/null +++ b/apps/refer/src/lib/handoff.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { schema, type DBType } from "@refref/coredb"; +import { generateOpaqueToken } from "@refref/webhooks"; + +const { handoffToken } = schema; + +/** SHA-256 hex of the plaintext token. We never store the plaintext. */ +export function hashToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export interface IssuedHandoff { + /** Plaintext token — goes ONLY into the redirect URL, never persisted. */ + token: string; + expiresAt: Date; +} + +/** + * Issue a single-use, short-lived hand-off token bound to a lead. Default TTL + * is 10 minutes. welfie-backend exchanges it server-side at signup. + */ +export async function issueHandoffToken( + db: DBType, + leadId: string, + ttlMinutes = 10, +): Promise { + const token = generateOpaqueToken(32); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + ttlMinutes * 60_000); + + await db.insert(handoffToken).values({ + tokenHash, + leadId, + expiresAt, + }); + + return { token, expiresAt }; +} diff --git a/apps/refer/src/lib/render.ts b/apps/refer/src/lib/render.ts new file mode 100644 index 0000000..dcd117a --- /dev/null +++ b/apps/refer/src/lib/render.ts @@ -0,0 +1,290 @@ +/** Minimal dependency-free HTML rendering for the public invite page. */ + +export function escapeHtml(input: string | null | undefined): string { + if (!input) return ""; + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export interface InvitePageParams { + referrerName: string | null; + code: string; + /** Absolute or root-relative path the form POSTs to. */ + actionPath: string; + consentVersion: string; + privacyPolicyUrl: string; + primaryColor: string; + turnstileSiteKey?: string; + /** Carried-through UTM params to re-post as hidden fields. */ + utm?: Record; + /** Sticky field values + error message when re-rendering after a failure. */ + values?: { + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + }; + error?: string; +} + +/** + * Render the personalised referee landing page. Includes a honeypot field + * ("company") and, if configured, a Cloudflare Turnstile widget. + */ +export function renderInvitePage(p: InvitePageParams): string { + const who = p.referrerName ? escapeHtml(p.referrerName) : "A friend"; + const color = brandColor(p.primaryColor); + const utmHidden = Object.entries(p.utm ?? {}) + .map( + ([k, v]) => + ``, + ) + .join(""); + + const turnstile = p.turnstileSiteKey + ? `
+ ` + : ""; + + const errorBlock = p.error + ? `` + : ""; + + return ` + + + + + +You've been invited to Welfie + + + +
+
+

${who} invited you to Welfie

+

Enter your details below and we'll get your Welfie account started.

+ ${errorBlock} +
+ + +

This is who invited you and can't be changed.

+ +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + ${utmHidden} + ${turnstile} + +
+

Referral code: ${escapeHtml(p.code)}

+
+
+ +`; +} + +export interface JoinPageParams { + /** Product the referral program belongs to, used in the page copy. */ + productName: string; + /** Product the form posts back, carried as a hidden field. */ + productId: string; + /** Absolute or root-relative path the form POSTs to. */ + actionPath: string; + consentVersion: string; + privacyPolicyUrl: string; + primaryColor: string; + turnstileSiteKey?: string; + values?: { email?: string; firstName?: string }; + error?: string; +} + +/** + * Render the public "get your own referral link" form. Unlike the invite page + * this has no referrer: an anonymous visitor identifies themselves by email + * only, and receives a link they can share. + */ +export function renderJoinPage(p: JoinPageParams): string { + const color = brandColor(p.primaryColor); + const product = escapeHtml(p.productName); + + const turnstile = p.turnstileSiteKey + ? `
+ ` + : ""; + + const errorBlock = p.error + ? `` + : ""; + + return ` + + + + + +Get your ${product} referral link + + + +
+
+

Get your ${product} referral link

+

Enter your email and we'll create a personal link you can share. Come back with the same email any time to get the same link.

+ ${errorBlock} +
+ + + + + + + + + + + + + + ${turnstile} + +
+
+
+ +`; +} + +export interface JoinSuccessPageParams { + productName: string; + referralLink: string; + code: string; + primaryColor: string; +} + +/** Render the referral link handed back to an anonymous referrer. */ +export function renderJoinSuccessPage(p: JoinSuccessPageParams): string { + const color = brandColor(p.primaryColor); + const product = escapeHtml(p.productName); + const link = escapeHtml(p.referralLink); + + return ` + + + + + +Your ${product} referral link + + + +
+
+

Your referral link is ready

+

Share this link with friends. When they sign up for ${product}, we'll credit the referral to you.

+ + +

Referral code: ${escapeHtml(p.code)}

+
+
+ + +`; +} + +function brandColor(raw: string): string { + return /^#[0-9A-Fa-f]{6}$/.test(raw) ? raw : "#4f46e5"; +} + +function baseStyles(color: string): string { + return ` + :root { --brand: ${color}; } + * { box-sizing: border-box; } + body { margin:0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background:#f6f7f9; color:#111827; } + .wrap { max-width: 460px; margin: 0 auto; padding: 48px 20px; } + .card { background:#fff; border-radius:16px; padding:32px; box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 10px 30px rgba(0,0,0,.05); } + h1 { font-size: 1.5rem; margin: 0 0 8px; } + p.lead { color:#4b5563; margin: 0 0 24px; } + label { display:block; font-size:.875rem; font-weight:600; margin: 16px 0 6px; } + input[type=email], input[type=text], input[type=tel] { width:100%; padding:12px 14px; border:1px solid #d1d5db; border-radius:10px; font-size:1rem; } + input:focus { outline: 2px solid var(--brand); border-color: var(--brand); } + input[readonly] { background:#f3f4f6; color:#4b5563; cursor:default; } + input[readonly]:focus { outline:none; border-color:#d1d5db; } + p.hint { margin:6px 0 0; font-size:.75rem; color:#6b7280; } + .row { display:flex; gap:12px; } + .row > div { flex:1; min-width:0; } + .consent { display:flex; gap:10px; align-items:flex-start; margin:20px 0; font-size:.8125rem; color:#4b5563; } + .consent input { margin-top:3px; } + button { width:100%; margin-top:20px; padding:13px 16px; border:0; border-radius:10px; background:var(--brand); color:#fff; font-size:1rem; font-weight:600; cursor:pointer; } + button:hover { filter: brightness(0.95); } + .error { background:#fef2f2; color:#991b1b; border:1px solid #fecaca; padding:10px 12px; border-radius:10px; font-size:.875rem; margin-bottom:16px; } + .hp { position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden; } + .foot { text-align:center; color:#9ca3af; font-size:.75rem; margin-top:20px; } + a { color: var(--brand); }`; +} diff --git a/apps/refer/src/routes/invite.ts b/apps/refer/src/routes/invite.ts new file mode 100644 index 0000000..4f58e9b --- /dev/null +++ b/apps/refer/src/routes/invite.ts @@ -0,0 +1,348 @@ +import { + FastifyInstance, + FastifyRequest, + FastifyReply, + FastifyPluginOptions, +} from "fastify"; +import { z } from "zod"; +import { schema } from "@refref/coredb"; +import { and, desc, eq } from "drizzle-orm"; +import { normalizeCode } from "@refref/utils"; +import { enqueueWebhook } from "@refref/webhooks"; +import type { + ProgramConfigV1Type, + ReferralCreatedPayload, +} from "@refref/types"; +import { renderInvitePage, escapeHtml } from "../lib/render.js"; +import { + isDisposableEmail, + looksLikeEmail, + normalizeEmail, + selfReferralReason, +} from "../lib/abuse.js"; +import { issueHandoffToken } from "../lib/handoff.js"; +import { loadReferConfig, verifyTurnstile, type ReferConfig } from "../lib/config.js"; + +const { refcode, referralLead } = schema; + +interface CodeParams { + code: string; +} + +const UTM_KEYS = ["source", "medium", "campaign", "term", "content"] as const; + +function collectUtm( + query: Record, +): Record { + const utm: Record = {}; + for (const key of UTM_KEYS) { + const v = query[`utm_${key}`]; + if (typeof v === "string" && v.length > 0 && v.length <= 200) { + utm[key] = v; + } + } + return utm; +} + +function notFoundPage(reply: FastifyReply, message: string) { + return reply + .code(404) + .type("text/html") + .send( + `Link unavailable` + + `` + + `

This referral link is invalid or has expired

` + + `

${escapeHtml(message)}

`, + ); +} + +/** Resolve a global refcode with its participant and program. */ +async function resolveCode(db: FastifyRequest["db"], code: string) { + const normalized = normalizeCode(code); + const result = await db.query.refcode.findFirst({ + where: eq(refcode.code, normalized), + with: { participant: true, program: true }, + }); + return { normalized, result }; +} + +const submitSchema = z.object({ + firstName: z + .string() + .trim() + .max(80) + .optional() + .transform((v) => (v ? v : undefined)), + lastName: z + .string() + .trim() + .max(80) + .optional() + .transform((v) => (v ? v : undefined)), + email: z.string().trim().max(254), + phone: z + .string() + .trim() + .max(32) + .optional() + .transform((v) => (v ? v : undefined)), + consent: z.string().optional(), + consent_version: z.string().max(120).optional(), + company: z.string().optional(), // honeypot + "cf-turnstile-response": z.string().optional(), +}); + +export default function inviteRoutes(config?: ReferConfig) { + const cfg = config ?? loadReferConfig(); + + return async function ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions, + ) { + const invitePath = "/invite/:code"; + + // GET: render the personalised landing page + form. + fastify.get<{ Params: CodeParams }>( + invitePath, + { + config: { rateLimit: { max: 60, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const { result, normalized } = await resolveCode( + request.db, + request.params.code, + ); + if (!result || !result.participant || !result.program) { + return notFoundPage(reply, "Ask your friend for a fresh link."); + } + + const programConfig = result.program.config as ProgramConfigV1Type | null; + const primaryColor = programConfig?.brandConfig?.primaryColor || "#4f46e5"; + const utm = collectUtm(request.query as Record); + + const html = renderInvitePage({ + referrerName: result.participant.name ?? null, + code: normalized, + actionPath: `${cfg.basePath}/invite/${encodeURIComponent(normalized)}`, + consentVersion: cfg.consentVersion, + privacyPolicyUrl: cfg.privacyPolicyUrl, + primaryColor, + turnstileSiteKey: cfg.turnstileSiteKey, + utm, + }); + return reply.code(200).type("text/html").send(html); + }, + ); + + // POST: validate, run abuse checks, create the lead, enqueue the webhook, + // issue a hand-off token, and redirect to Welfie signup. + fastify.post<{ Params: CodeParams }>( + invitePath, + { + config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, + }, + async (request, reply) => { + const body = submitSchema.safeParse(request.body ?? {}); + const { result, normalized } = await resolveCode( + request.db, + request.params.code, + ); + + if (!result || !result.participant || !result.program) { + return notFoundPage(reply, "Ask your friend for a fresh link."); + } + + const programConfig = result.program.config as ProgramConfigV1Type | null; + const primaryColor = programConfig?.brandConfig?.primaryColor || "#4f46e5"; + const utm = collectUtm(request.body as Record); + const actionPath = `${cfg.basePath}/invite/${encodeURIComponent(normalized)}`; + + const rerender = ( + error: string, + values?: { + email?: string; + firstName?: string; + lastName?: string; + phone?: string; + }, + ) => + reply.code(400).type("text/html").send( + renderInvitePage({ + referrerName: result.participant!.name ?? null, + code: normalized, + actionPath, + consentVersion: cfg.consentVersion, + privacyPolicyUrl: cfg.privacyPolicyUrl, + primaryColor, + turnstileSiteKey: cfg.turnstileSiteKey, + utm, + values, + error, + }), + ); + + if (!body.success) { + return rerender("Please check your details and try again."); + } + const data = body.data; + + // Honeypot: a filled hidden field means a bot. Pretend success, persist + // nothing, and send it onward with no token. + if (data.company && data.company.trim().length > 0) { + request.log.warn({ code: normalized }, "honeypot triggered"); + return reply.code(303).redirect(cfg.signupUrl); + } + + const email = normalizeEmail(data.email); + const firstName = data.firstName; + const lastName = data.lastName; + const phone = data.phone; + const sticky = { email, firstName, lastName, phone }; + + if (!looksLikeEmail(email)) { + return rerender("Please enter a valid email address.", { + ...sticky, + email: undefined, + }); + } + if (data.consent !== "yes") { + return rerender("Please agree to the privacy policy to continue.", sticky); + } + + // CAPTCHA (only enforced when a secret is configured). + const captchaOk = await verifyTurnstile( + cfg, + data["cf-turnstile-response"], + request.ip, + ); + if (!captchaOk) { + return rerender("Verification failed. Please try again.", sticky); + } + + if (isDisposableEmail(email, cfg.extraDisposableDomains)) { + return rerender( + "Please use a permanent, non-disposable email address.", + { ...sticky, email: undefined }, + ); + } + + const selfRef = selfReferralReason(email, { + email: result.participant.email, + }); + if (selfRef) { + request.log.warn( + { code: normalized, reason: selfRef }, + "self-referral blocked", + ); + return rerender( + "This email address can't be used with this referral link.", + { ...sticky, email: undefined }, + ); + } + + const programId = result.programId; + const productId = result.productId; + const referrerParticipantId = result.participantId; + + // Reuse an existing recent pending lead for the same code+email to keep + // a double submit idempotent; otherwise create a fresh lead. + const existing = await request.db.query.referralLead.findFirst({ + where: and( + eq(referralLead.code, normalized), + eq(referralLead.email, email), + eq(referralLead.status, "pending"), + ), + orderBy: desc(referralLead.createdAt), + }); + + const landingUrl = `${cfg.basePath}/invite/${normalized}`; + const nowIso = new Date().toISOString(); + + let lead = existing; + if (!lead) { + const [inserted] = await request.db + .insert(referralLead) + .values({ + programId, + productId, + referrerParticipantId, + refcodeId: result.id, + code: normalized, + email, + firstName: firstName ?? null, + lastName: lastName ?? null, + phone: phone ?? null, + status: "pending", + emailVerified: false, + consentVersion: data.consent_version || cfg.consentVersion, + consentAt: new Date(), + landingUrl, + utm: Object.keys(utm).length ? utm : null, + ip: request.ip, + userAgent: request.headers["user-agent"] ?? null, + }) + .returning(); + lead = inserted; + } + + if (!lead) { + request.log.error({ code: normalized }, "failed to persist lead"); + return rerender("Something went wrong. Please try again."); + } + + // Enqueue referral.created (async, dedupe on the lead id). + const payload: Omit = { + event: "referral.created", + occurred_at: nowIso, + program_id: programId, + referral: { id: lead.id, code: normalized, status: "pending" }, + referrer: { + participant_id: referrerParticipantId, + external_id: result.participant.externalId ?? null, + email: result.participant.email ?? null, + name: result.participant.name ?? null, + }, + referee: { + email, + first_name: firstName ?? null, + last_name: lastName ?? null, + phone: phone ?? null, + email_verified: false, + form_fields: {}, + }, + consent: { + version: lead.consentVersion || cfg.consentVersion, + accepted_at: (lead.consentAt ?? new Date()).toISOString(), + }, + attribution: { + landing_url: landingUrl, + utm, + ip: request.ip, + user_agent: request.headers["user-agent"] ?? null, + }, + }; + + try { + await enqueueWebhook(request.db, { + productId, + eventType: "referral.created", + payload: payload as unknown as Record, + dedupeKey: `created:${lead.id}`, + }); + } catch (err) { + // Never fail the form on an enqueue hiccup — the lead is already saved. + request.log.error({ err }, "failed to enqueue referral.created"); + } + + // Issue single-use hand-off token and redirect to signup. + const { token } = await issueHandoffToken( + request.db, + lead.id, + cfg.handoffTtlMinutes, + ); + const redirectUrl = `${cfg.signupUrl}?lt=${encodeURIComponent(token)}`; + return reply.code(303).redirect(redirectUrl); + }, + ); + }; +} diff --git a/apps/refer/src/routes/join.ts b/apps/refer/src/routes/join.ts new file mode 100644 index 0000000..7d68158 --- /dev/null +++ b/apps/refer/src/routes/join.ts @@ -0,0 +1,298 @@ +import { + FastifyInstance, + FastifyPluginOptions, + FastifyReply, + FastifyRequest, +} from "fastify"; +import { z } from "zod"; +import { schema } from "@refref/coredb"; +import { and, asc, desc, eq } from "drizzle-orm"; +import { generateGlobalCode } from "@refref/utils"; +import type { ProgramConfigV1Type } from "@refref/types"; +import { + escapeHtml, + renderJoinPage, + renderJoinSuccessPage, +} from "../lib/render.js"; +import { + isDisposableEmail, + looksLikeEmail, + normalizeEmail, +} from "../lib/abuse.js"; +import { + loadReferConfig, + verifyTurnstile, + type ReferConfig, +} from "../lib/config.js"; + +const { participant, product, program, refcode } = schema; + +/** + * External id namespace for participants who were created from the public + * form rather than from an authenticated session in the host product. Keeping + * the prefix makes them trivially distinguishable, and makes the email the + * stable key so a repeat submit returns the same link. + */ +const ANON_EXTERNAL_ID_PREFIX = "anon:"; + +const submitSchema = z.object({ + productId: z.string().trim().min(1).max(64), + firstName: z + .string() + .trim() + .max(80) + .optional() + .transform((v) => (v ? v : undefined)), + email: z.string().trim().max(254), + consent: z.string().optional(), + consent_version: z.string().max(120).optional(), + company: z.string().optional(), // honeypot + "cf-turnstile-response": z.string().optional(), +}); + +const querySchema = z.object({ + productId: z.string().trim().min(1).max(64), +}); + +function unavailablePage(reply: FastifyReply, message: string) { + return reply + .code(404) + .type("text/html") + .send( + `Unavailable` + + `` + + `

This referral program isn't available

` + + `

${escapeHtml(message)}

`, + ); +} + +/** Load the product plus its oldest active program, which owns the refcode. */ +async function resolveProgram(db: FastifyRequest["db"], productId: string) { + const productRecord = await db.query.product.findFirst({ + where: eq(product.id, productId), + }); + if (!productRecord) return null; + + const activeProgram = await db.query.program.findFirst({ + where: and(eq(program.productId, productId), eq(program.status, "active")), + orderBy: asc(program.createdAt), + }); + if (!activeProgram) return null; + + return { product: productRecord, program: activeProgram }; +} + +/** + * Return the participant's existing refcode for this program, or mint a new + * one. Codes are globally unique, so an insert can lose a race; retry with a + * freshly generated code before giving up. + */ +async function getOrCreateRefcode( + db: FastifyRequest["db"], + participantId: string, + programId: string, + productId: string, +) { + const existing = await db.query.refcode.findFirst({ + where: and( + eq(refcode.participantId, participantId), + eq(refcode.programId, programId), + ), + orderBy: desc(refcode.createdAt), + }); + if (existing) return existing; + + for (let attempt = 0; attempt < 3; attempt++) { + const code = generateGlobalCode(5); + if (!code) break; + + const [inserted] = await db + .insert(refcode) + .values({ code, participantId, programId, productId }) + .onConflictDoNothing() + .returning(); + if (inserted) return inserted; + } + + return null; +} + +export default function joinRoutes(config?: ReferConfig) { + const cfg = config ?? loadReferConfig(); + + return async function ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions, + ) { + const joinPath = "/join"; + const actionPath = `${cfg.basePath}${joinPath}`; + + // GET: render the public form. The product is chosen by query param so a + // single deployment can serve every product's program. + fastify.get( + joinPath, + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (request, reply) => { + const query = querySchema.safeParse(request.query ?? {}); + if (!query.success) { + return unavailablePage(reply, "Missing product."); + } + + const resolved = await resolveProgram(request.db, query.data.productId); + if (!resolved) { + return unavailablePage(reply, "No active referral program found."); + } + + const programConfig = resolved.program + .config as ProgramConfigV1Type | null; + + return reply + .code(200) + .type("text/html") + .send( + renderJoinPage({ + productName: resolved.product.name, + productId: resolved.product.id, + actionPath, + consentVersion: cfg.consentVersion, + privacyPolicyUrl: cfg.privacyPolicyUrl, + primaryColor: programConfig?.brandConfig?.primaryColor || "", + turnstileSiteKey: cfg.turnstileSiteKey, + }), + ); + }, + ); + + // POST: run the same abuse checks as the invite form, then upsert an + // anonymous participant keyed on their email and hand back their link. + fastify.post( + joinPath, + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + const body = submitSchema.safeParse(request.body ?? {}); + if (!body.success) { + return unavailablePage(reply, "Missing product."); + } + const data = body.data; + + const resolved = await resolveProgram(request.db, data.productId); + if (!resolved) { + return unavailablePage(reply, "No active referral program found."); + } + + const programConfig = resolved.program + .config as ProgramConfigV1Type | null; + const primaryColor = programConfig?.brandConfig?.primaryColor || ""; + + const rerender = ( + error: string, + values?: { email?: string; firstName?: string }, + ) => + reply + .code(400) + .type("text/html") + .send( + renderJoinPage({ + productName: resolved.product.name, + productId: resolved.product.id, + actionPath, + consentVersion: cfg.consentVersion, + privacyPolicyUrl: cfg.privacyPolicyUrl, + primaryColor, + turnstileSiteKey: cfg.turnstileSiteKey, + values, + error, + }), + ); + + // Honeypot: a filled hidden field means a bot. Persist nothing and + // show a generic failure rather than minting a code. + if (data.company && data.company.trim().length > 0) { + request.log.warn({ productId: data.productId }, "honeypot triggered"); + return rerender("Something went wrong. Please try again."); + } + + const email = normalizeEmail(data.email); + const firstName = data.firstName; + + if (!looksLikeEmail(email)) { + return rerender("Please enter a valid email address.", { firstName }); + } + if (data.consent !== "yes") { + return rerender("Please agree to the privacy policy to continue.", { + email, + firstName, + }); + } + + const captchaOk = await verifyTurnstile( + cfg, + data["cf-turnstile-response"], + request.ip, + ); + if (!captchaOk) { + return rerender("Verification failed. Please try again.", { + email, + firstName, + }); + } + + if (isDisposableEmail(email, cfg.extraDisposableDomains)) { + return rerender( + "Please use a permanent, non-disposable email address.", + { firstName }, + ); + } + + const [participantRecord] = await request.db + .insert(participant) + .values({ + externalId: `${ANON_EXTERNAL_ID_PREFIX}${email}`, + productId: resolved.product.id, + email, + name: firstName ?? null, + }) + .onConflictDoUpdate({ + target: [participant.productId, participant.externalId], + set: { email, name: firstName ?? null }, + }) + .returning(); + + if (!participantRecord) { + request.log.error( + { productId: resolved.product.id }, + "failed to upsert anonymous participant", + ); + return rerender("Something went wrong. Please try again."); + } + + const refcodeRecord = await getOrCreateRefcode( + request.db, + participantRecord.id, + resolved.program.id, + resolved.product.id, + ); + + if (!refcodeRecord) { + request.log.error( + { participantId: participantRecord.id }, + "failed to create refcode", + ); + return rerender("Something went wrong. Please try again."); + } + + return reply + .code(200) + .type("text/html") + .send( + renderJoinSuccessPage({ + productName: resolved.product.name, + referralLink: `${cfg.referralHostUrl}/${refcodeRecord.code}`, + code: refcodeRecord.code, + primaryColor, + }), + ); + }, + ); + }; +} diff --git a/apps/refer/src/routes/r.ts b/apps/refer/src/routes/r.ts index cc71b48..20ad3c9 100644 --- a/apps/refer/src/routes/r.ts +++ b/apps/refer/src/routes/r.ts @@ -1,9 +1,14 @@ -import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import { + FastifyInstance, + FastifyPluginOptions, + FastifyRequest, + FastifyReply, +} from "fastify"; import { schema } from "@refref/coredb"; const { refcode, product, reflink } = schema; import { eq, and } from "drizzle-orm"; import { normalizeCode } from "@refref/utils"; -import type { ProgramConfigV1Type } from "@refref/types"; +import { loadReferConfig, type ReferConfig } from "../lib/config.js"; interface GlobalCodeParams { code: string; @@ -14,195 +19,141 @@ interface LocalCodeParams { code: string; } -export default async function referralRedirectRoutes(fastify: FastifyInstance) { - /** - * Handles GET requests to /r/:code (auto-generated refcodes) - * Example: /r/abc1234 - * - * Auto-generated codes are unique across the entire system and don't require product context. - */ - fastify.get<{ Params: GlobalCodeParams }>( - "/:code", - { - config: { - rateLimit: { - max: 100, - timeWindow: "1 minute", - }, - }, - }, - async ( - request: FastifyRequest<{ Params: GlobalCodeParams }>, - reply: FastifyReply, - ) => { - try { - const { code } = request.params; - const normalizedCode = normalizeCode(code); - - // Single optimized query using relations - // This does a JOIN under the hood: refcode → participant → program - const result = await request.db.query.refcode.findFirst({ - where: eq(refcode.code, normalizedCode), - with: { - participant: true, - program: true, - }, - }); - - if (!result || !result.participant) { - return reply.code(404).send({ error: "Referral code not found" }); - } - - // Type assertion needed due to Drizzle's type inference limitations with nested relations - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const participantRecord = result.participant as any; - - // Get the landing page URL from program config (assuming it's always present) - const programConfig = result.program!.config as ProgramConfigV1Type; - const redirectUrl = programConfig.brandConfig!.landingPageUrl; - - if (!redirectUrl) { - request.log.error({ - programId: result.programId, - message: "No landing page URL configured in brand config", - }); - return reply.code(500).send({ - error: "Landing page URL not configured for this program", - }); - } - - // Helper to encode and only add non-empty values - const encode = (value: string | null | undefined) => - value ? Buffer.from(value, "utf-8").toString("base64") : undefined; - - // Extra params, potentially enabled/disabled via config - const paramsObj: Record = { - name: encode(participantRecord.name), - participantId: encode(participantRecord.id), - }; - - const searchParams = new URLSearchParams(); - Object.entries(paramsObj).forEach(([key, value]) => { - if (value) searchParams.set(key, value); - }); - searchParams.set("refcode", normalizedCode); - - // Redirect with 307 to the product URL with encoded params - return reply - .code(307) - .redirect(`${redirectUrl}?${searchParams.toString()}`); - } catch (error) { - // Log error and return 500 - request.log.error({ error }, "Error in referral redirect handler"); - return reply.code(500).send({ error: "Internal Server Error" }); +/** + * Carry the inbound query string (utm_*, etc.) onto the invite page so the form + * can re-post it as hidden fields and attribution survives the hop. + */ +function inviteTarget(cfg: ReferConfig, code: string, query: unknown): string { + const params = new URLSearchParams(); + if (query && typeof query === "object") { + for (const [key, value] of Object.entries( + query as Record, + )) { + if (typeof value === "string" && value.length > 0 && value.length <= 200) { + params.set(key, value); } - }, - ); + } + } + const qs = params.toString(); + const path = `${cfg.basePath}/invite/${encodeURIComponent(code)}`; + return qs ? `${path}?${qs}` : path; +} - /** - * Handles GET requests to /r/:productSlug/:code (vanity links via reflink table) - * Example: /r/acme/john-doe - * - * Vanity links are unique within a product and require the product slug for disambiguation. - * This allows for vanity URLs like /r/acme/ceo or /r/startup/founder - */ - fastify.get<{ Params: LocalCodeParams }>( - "/:productSlug/:code", - { - config: { - rateLimit: { - max: 100, - timeWindow: "1 minute", +export default function referralRedirectRoutes(config?: ReferConfig) { + const cfg = config ?? loadReferConfig(); + + return async function ( + fastify: FastifyInstance, + _opts: FastifyPluginOptions, + ) { + /** + * Handles GET requests to /:code (auto-generated refcodes) + * Example: /abc1234 + * + * Auto-generated codes are unique across the entire system and don't + * require product context. The visitor lands on the personalised invite + * form, which captures their details before handing off to signup. + */ + fastify.get<{ Params: GlobalCodeParams }>( + "/:code", + { + config: { + rateLimit: { + max: 100, + timeWindow: "1 minute", + }, }, }, - }, - async ( - request: FastifyRequest<{ Params: LocalCodeParams }>, - reply: FastifyReply, - ) => { - try { - const { productSlug, code } = request.params; - const normalizedCode = normalizeCode(code); + async ( + request: FastifyRequest<{ Params: GlobalCodeParams }>, + reply: FastifyReply, + ) => { + try { + const { code } = request.params; + const normalizedCode = normalizeCode(code); + + const result = await request.db.query.refcode.findFirst({ + where: eq(refcode.code, normalizedCode), + with: { participant: true }, + }); - // First find the product by slug - const productRecord = await request.db.query.product.findFirst({ - where: eq(product.slug, productSlug), - }); + if (!result || !result.participant) { + return reply.code(404).send({ error: "Referral code not found" }); + } - if (!productRecord) { - return reply.code(404).send({ error: "Product not found" }); + return reply + .code(307) + .redirect(inviteTarget(cfg, normalizedCode, request.query)); + } catch (error) { + request.log.error({ error }, "Error in referral redirect handler"); + return reply.code(500).send({ error: "Internal Server Error" }); } + }, + ); + + /** + * Handles GET requests to /:productSlug/:code (vanity links via reflink table) + * Example: /acme/john-doe + * + * Vanity links are unique within a product and require the product slug for + * disambiguation. + */ + fastify.get<{ Params: LocalCodeParams }>( + "/:productSlug/:code", + { + config: { + rateLimit: { + max: 100, + timeWindow: "1 minute", + }, + }, + }, + async ( + request: FastifyRequest<{ Params: LocalCodeParams }>, + reply: FastifyReply, + ) => { + try { + const { productSlug, code } = request.params; + const normalizedCode = normalizeCode(code); + + const productRecord = await request.db.query.product.findFirst({ + where: eq(product.slug, productSlug), + }); - // Look up the vanity link in the reflink table - const reflinkResult = await request.db.query.reflink.findFirst({ - where: and( - eq(reflink.slug, normalizedCode), - eq(reflink.productId, productRecord.id), - ), - with: { - refcode: { - with: { - participant: true, - program: true, + if (!productRecord) { + return reply.code(404).send({ error: "Product not found" }); + } + + const reflinkResult = await request.db.query.reflink.findFirst({ + where: and( + eq(reflink.slug, normalizedCode), + eq(reflink.productId, productRecord.id), + ), + with: { + refcode: { + with: { participant: true }, }, }, - }, - }); - - if ( - !reflinkResult || - !reflinkResult.refcode || - !reflinkResult.refcode.participant - ) { - return reply.code(404).send({ error: "Referral link not found" }); - } - - const result = reflinkResult.refcode; - - // Type assertion needed due to Drizzle's type inference limitations with nested relations - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const participantRecord = result.participant as any; - - // Get the landing page URL from program config (assuming it's always present) - const programConfig = result.program!.config as ProgramConfigV1Type; - const redirectUrl = programConfig.brandConfig!.landingPageUrl; - - if (!redirectUrl) { - request.log.error({ - programId: result.programId, - message: "No landing page URL configured in brand config", - }); - return reply.code(500).send({ - error: "Landing page URL not configured for this program", }); - } - - // Helper to encode and only add non-empty values - const encode = (value: string | null | undefined) => - value ? Buffer.from(value, "utf-8").toString("base64") : undefined; - - // Extra params, potentially enabled/disabled via config - const paramsObj: Record = { - name: encode(participantRecord.name), - email: encode(participantRecord.email), - participantId: encode(participantRecord.id), - }; - const searchParams = new URLSearchParams(); - Object.entries(paramsObj).forEach(([key, value]) => { - if (value) searchParams.set(key, value); - }); - searchParams.set("refcode", result.code); - - // Redirect with 307 to the product URL with encoded params - return reply - .code(307) - .redirect(`${redirectUrl}?${searchParams.toString()}`); - } catch (error) { - // Log error and return 500 - request.log.error({ error }, "Error in referral redirect handler"); - return reply.code(500).send({ error: "Internal Server Error" }); - } - }, - ); + if ( + !reflinkResult || + !reflinkResult.refcode || + !reflinkResult.refcode.participant + ) { + return reply.code(404).send({ error: "Referral link not found" }); + } + + return reply + .code(307) + .redirect( + inviteTarget(cfg, reflinkResult.refcode.code, request.query), + ); + } catch (error) { + request.log.error({ error }, "Error in referral redirect handler"); + return reply.code(500).send({ error: "Internal Server Error" }); + } + }, + ); + }; } diff --git a/apps/refer/test/abuse.test.ts b/apps/refer/test/abuse.test.ts new file mode 100644 index 0000000..95c4b93 --- /dev/null +++ b/apps/refer/test/abuse.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + isDisposableEmail, + selfReferralReason, + normalizeEmail, + emailDomain, + looksLikeEmail, + parseExtraDisposableDomains, +} from "../src/lib/abuse.js"; + +describe("email helpers", () => { + it("normalizes and extracts domains", () => { + expect(normalizeEmail(" Foo@Bar.COM ")).toBe("foo@bar.com"); + expect(emailDomain("foo@bar.com")).toBe("bar.com"); + expect(emailDomain("nope")).toBeNull(); + expect(looksLikeEmail("a@b.co")).toBe(true); + expect(looksLikeEmail("a@b")).toBe(false); + }); +}); + +describe("isDisposableEmail", () => { + it("flags known disposable domains", () => { + expect(isDisposableEmail("x@mailinator.com")).toBe(true); + expect(isDisposableEmail("x@yopmail.com")).toBe(true); + }); + it("allows normal domains", () => { + expect(isDisposableEmail("x@gmail.com")).toBe(false); + expect(isDisposableEmail("x@welfie.com")).toBe(false); + }); + it("honours extra domains from config", () => { + const extra = parseExtraDisposableDomains("evil.test, spam.example"); + expect(isDisposableEmail("x@evil.test", extra)).toBe(true); + expect(isDisposableEmail("x@spam.example", extra)).toBe(true); + }); +}); + +describe("selfReferralReason", () => { + it("blocks the referrer's own email", () => { + expect(selfReferralReason("me@corp.com", { email: "me@corp.com" })).toBe( + "same_email", + ); + }); + it("does NOT block a shared custom domain", () => { + expect( + selfReferralReason("friend@corp.com", { email: "me@corp.com" }), + ).toBeNull(); + }); + it("does NOT block a shared free provider", () => { + expect( + selfReferralReason("friend@gmail.com", { email: "me@gmail.com" }), + ).toBeNull(); + }); + it("allows genuinely different addresses", () => { + expect( + selfReferralReason("friend@other.com", { email: "me@corp.com" }), + ).toBeNull(); + }); + it("is a no-op when the referrer has no email", () => { + expect(selfReferralReason("friend@other.com", { email: null })).toBeNull(); + }); +}); diff --git a/apps/refer/test/referral-redirect.test.ts b/apps/refer/test/referral-redirect.test.ts index a8f4c39..df8a1ae 100644 --- a/apps/refer/test/referral-redirect.test.ts +++ b/apps/refer/test/referral-redirect.test.ts @@ -59,8 +59,8 @@ describe("Referral Redirect Endpoint", () => { expect(body.error).toBe("Referral code not found"); }); - it("should return 500 when product URL not configured", async () => { - // Mock database to return nested data with no landing page URL (relational query) + it("should redirect to the invite form even with no landing page URL", async () => { + // The invite form is the destination now, so brandConfig is irrelevant here. mockDb.query.refcode.findFirst.mockResolvedValueOnce({ id: "rc_123", code: "abc1234", @@ -85,17 +85,15 @@ describe("Referral Redirect Endpoint", () => { const response = await apiContext.get("/abc1234"); - expect(response.status()).toBe(500); - - const body = await response.json(); - expect(body.error).toBe( - "Landing page URL not configured for this program", + expect(response.status()).toBe(307); + expect(response.headers()["location"]).toMatch( + /\/invite\/abc1234(\?|$)/, ); }); }); describe("GET /:code - Refcode Success Cases", () => { - it("should redirect with encoded params when all data is present", async () => { + it("should redirect to the personalised invite form", async () => { // Mock complete happy path with nested data (relational query) mockDb.query.refcode.findFirst.mockResolvedValueOnce({ id: "rc_happy", @@ -125,10 +123,10 @@ describe("Referral Redirect Endpoint", () => { const location = response.headers()["location"]; expect(location).toBeDefined(); - expect(location).toContain("https://example.com"); - expect(location).toContain("refcode=abc1234"); - expect(location).toContain("name="); // Base64 encoded name - expect(location).toContain("participantId="); // Base64 encoded participant ID + expect(location).toMatch(/\/invite\/abc1234(\?|$)/); + // Referrer details are resolved from the code by the form, so they are + // no longer leaked into the redirect URL. + expect(location).not.toContain("participantId="); }); it("should handle missing optional fields gracefully", async () => { @@ -161,13 +159,11 @@ describe("Referral Redirect Endpoint", () => { const location = response.headers()["location"]; expect(location).toBeDefined(); - expect(location).toContain("https://minimal.example.com"); - expect(location).toContain("refcode=xyz5678"); - // Should not include empty encoded params + expect(location).toMatch(/\/invite\/xyz5678(\?|$)/); expect(location).not.toContain("name="); }); - it("should preserve existing query params in product URL", async () => { + it("should forward utm params to the invite form", async () => { // Mock with nested data (relational query) mockDb.query.refcode.findFirst.mockResolvedValueOnce({ id: "rc_query", @@ -191,14 +187,17 @@ describe("Referral Redirect Endpoint", () => { }, }); - const response = await apiContext.get("/def9012"); + const response = await apiContext.get( + "/def9012?utm_source=whatsapp&utm_campaign=spring", + ); expect(response.status()).toBe(307); const location = response.headers()["location"]; expect(location).toBeDefined(); - // Should handle existing query params correctly - expect(location).toContain("refcode=def9012"); + expect(location).toContain("/invite/def9012?"); + expect(location).toContain("utm_source=whatsapp"); + expect(location).toContain("utm_campaign=spring"); }); it("should handle case-insensitive codes", async () => { @@ -230,7 +229,7 @@ describe("Referral Redirect Endpoint", () => { expect(response.status()).toBe(307); const location = response.headers()["location"]; - expect(location).toContain("refcode=abc1234"); // normalized to lowercase + expect(location).toMatch(/\/invite\/abc1234(\?|$)/); // normalized to lowercase }); }); @@ -309,15 +308,13 @@ describe("Referral Redirect Endpoint", () => { const location = response.headers()["location"]; expect(location).toBeDefined(); - expect(location).toContain("https://acme.example.com"); - expect(location).toContain("refcode=abc1234"); - expect(location).toContain("name="); - expect(location).toContain("email="); + // The vanity slug resolves to its underlying refcode before the hand-off. + expect(location).toMatch(/\/invite\/abc1234(\?|$)/); }); }); - describe("GET /:code - Parameter Encoding", () => { - it("should base64 encode participant details", async () => { + describe("GET /:code - Redirect Target", () => { + it("should not leak participant details into the redirect URL", async () => { // Mock with nested data (relational query) mockDb.query.refcode.findFirst.mockResolvedValueOnce({ id: "rc_encode", @@ -345,28 +342,11 @@ describe("Referral Redirect Endpoint", () => { expect(response.status()).toBe(307); - const location = response.headers()["location"]; - const url = new URL(location!); - - // Verify base64 encoding - const nameParam = url.searchParams.get("name"); - const participantIdParam = url.searchParams.get("participantId"); - - expect(nameParam).toBeDefined(); - expect(participantIdParam).toBeDefined(); - - // Decode and verify - if (nameParam) { - const decodedName = Buffer.from(nameParam, "base64").toString("utf-8"); - expect(decodedName).toBe("Test User"); - } - - if (participantIdParam) { - const decodedId = Buffer.from(participantIdParam, "base64").toString( - "utf-8", - ); - expect(decodedId).toBe("prt_encode"); - } + const location = response.headers()["location"]!; + expect(location).toMatch(/\/invite\/enc0123(\?|$)/); + expect(location).not.toContain("name="); + expect(location).not.toContain("email="); + expect(location).not.toContain("participantId="); }); it("should handle special characters in participant data", async () => { @@ -396,19 +376,11 @@ describe("Referral Redirect Endpoint", () => { const response = await apiContext.get("/spc4567"); expect(response.status()).toBe(307); - - const location = response.headers()["location"]; - expect(location).toBeDefined(); - - const url = new URL(location!); - const nameParam = url.searchParams.get("name"); - - // Should handle special characters through base64 encoding - expect(nameParam).toBeDefined(); - if (nameParam) { - const decodedName = Buffer.from(nameParam, "base64").toString("utf-8"); - expect(decodedName).toBe("John O'Brien & Co."); - } + // The name is rendered by the invite page, not encoded into the URL, so + // special characters cannot break the redirect. + expect(response.headers()["location"]).toMatch( + /\/invite\/spc4567(\?|$)/, + ); }); }); diff --git a/docker-compose.yml b/docker-compose.yml index deccf81..3fea05c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,5 +43,34 @@ services: pnpm start " + refer: + build: + context: . + dockerfile: Dockerfile.refer + container_name: refref-refer + ports: + - "3002:3002" + environment: + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/refref + # The image ships prod deps only, so pino-pretty is absent and the logger + # must stay in JSON mode. + NODE_ENV: production + PORT: 3002 + HOST: 0.0.0.0 + LOG_LEVEL: ${LOG_LEVEL:-info} + REFER_BASE_PATH: ${REFER_BASE_PATH:-/refer} + # Must be the origin this container is reachable on, since /:code is + # served at the root and the /join page hands these links out. + REFERRAL_HOST_URL: ${REFERRAL_HOST_URL:-http://localhost:3002} + WELFIE_SIGNUP_URL: ${WELFIE_SIGNUP_URL:-http://localhost:3000/auth/sign-up} + REFERRAL_CONSENT_VERSION: ${REFERRAL_CONSENT_VERSION:-privacy-2026-08} + PRIVACY_POLICY_URL: ${PRIVACY_POLICY_URL:-http://localhost:3000/privacy} + HANDOFF_TTL_MINUTES: ${HANDOFF_TTL_MINUTES:-10} + TURNSTILE_SITE_KEY: ${TURNSTILE_SITE_KEY:-} + TURNSTILE_SECRET: ${TURNSTILE_SECRET:-} + depends_on: + postgres: + condition: service_healthy + volumes: postgres_data: \ No newline at end of file diff --git a/packages/coredb/drizzle/0003_bumpy_captain_flint.sql b/packages/coredb/drizzle/0003_bumpy_captain_flint.sql new file mode 100644 index 0000000..51c9d00 --- /dev/null +++ b/packages/coredb/drizzle/0003_bumpy_captain_flint.sql @@ -0,0 +1,90 @@ +CREATE TABLE "handoff_token" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "token_hash" text NOT NULL, + "lead_id" text NOT NULL, + "expires_at" timestamp NOT NULL, + "consumed_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "referral_lead" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "program_id" text NOT NULL, + "product_id" text NOT NULL, + "referrer_participant_id" text NOT NULL, + "refcode_id" text, + "code" text NOT NULL, + "email" text NOT NULL, + "first_name" text, + "status" text DEFAULT 'pending' NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "consent_version" text, + "consent_at" timestamp, + "landing_url" text, + "utm" jsonb, + "ip" text, + "user_agent" text, + "qualified_referral_id" text, + "matched_by" text, + "qualified_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "webhook_delivery" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "event_id" text NOT NULL, + "dedupe_key" text, + "endpoint_id" text, + "product_id" text NOT NULL, + "event_type" text NOT NULL, + "payload" jsonb NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 8 NOT NULL, + "last_status" text DEFAULT 'pending' NOT NULL, + "last_error" text, + "last_response_code" integer, + "next_retry_at" timestamp DEFAULT now(), + "delivered_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "webhook_endpoint" ( + "id" text PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "product_id" text NOT NULL, + "url" text NOT NULL, + "secret" text NOT NULL, + "secondary_secret" text, + "is_active" boolean DEFAULT true NOT NULL, + "event_types" jsonb DEFAULT '["referral.created","referral.qualified"]'::jsonb NOT NULL +); +--> statement-breakpoint +ALTER TABLE "handoff_token" ADD CONSTRAINT "handoff_token_lead_id_referral_lead_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."referral_lead"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD CONSTRAINT "referral_lead_program_id_program_id_fk" FOREIGN KEY ("program_id") REFERENCES "public"."program"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD CONSTRAINT "referral_lead_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD CONSTRAINT "referral_lead_referrer_participant_id_participant_id_fk" FOREIGN KEY ("referrer_participant_id") REFERENCES "public"."participant"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD CONSTRAINT "referral_lead_refcode_id_refcode_id_fk" FOREIGN KEY ("refcode_id") REFERENCES "public"."refcode"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD CONSTRAINT "referral_lead_qualified_referral_id_referral_id_fk" FOREIGN KEY ("qualified_referral_id") REFERENCES "public"."referral"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_endpoint_id_webhook_endpoint_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoint"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "webhook_endpoint" ADD CONSTRAINT "webhook_endpoint_product_id_product_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."product"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "handoff_token_hash_unique_idx" ON "handoff_token" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "handoff_token_lead_id_idx" ON "handoff_token" USING btree ("lead_id");--> statement-breakpoint +CREATE INDEX "handoff_token_expires_at_idx" ON "handoff_token" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "referral_lead_email_idx" ON "referral_lead" USING btree ("email");--> statement-breakpoint +CREATE INDEX "referral_lead_status_idx" ON "referral_lead" USING btree ("status");--> statement-breakpoint +CREATE INDEX "referral_lead_code_idx" ON "referral_lead" USING btree ("code");--> statement-breakpoint +CREATE INDEX "referral_lead_program_id_idx" ON "referral_lead" USING btree ("program_id");--> statement-breakpoint +CREATE INDEX "referral_lead_referrer_idx" ON "referral_lead" USING btree ("referrer_participant_id");--> statement-breakpoint +CREATE INDEX "referral_lead_created_at_idx" ON "referral_lead" USING btree ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "webhook_delivery_event_id_unique_idx" ON "webhook_delivery" USING btree ("event_id");--> statement-breakpoint +CREATE UNIQUE INDEX "webhook_delivery_dedupe_key_unique_idx" ON "webhook_delivery" USING btree ("dedupe_key");--> statement-breakpoint +CREATE INDEX "webhook_delivery_status_idx" ON "webhook_delivery" USING btree ("last_status");--> statement-breakpoint +CREATE INDEX "webhook_delivery_next_retry_at_idx" ON "webhook_delivery" USING btree ("next_retry_at");--> statement-breakpoint +CREATE INDEX "webhook_delivery_event_type_idx" ON "webhook_delivery" USING btree ("event_type");--> statement-breakpoint +CREATE INDEX "webhook_endpoint_product_id_idx" ON "webhook_endpoint" USING btree ("product_id");--> statement-breakpoint +CREATE INDEX "webhook_endpoint_is_active_idx" ON "webhook_endpoint" USING btree ("is_active"); \ No newline at end of file diff --git a/packages/coredb/drizzle/0004_nostalgic_multiple_man.sql b/packages/coredb/drizzle/0004_nostalgic_multiple_man.sql new file mode 100644 index 0000000..5819082 --- /dev/null +++ b/packages/coredb/drizzle/0004_nostalgic_multiple_man.sql @@ -0,0 +1,2 @@ +ALTER TABLE "referral_lead" ADD COLUMN "last_name" text;--> statement-breakpoint +ALTER TABLE "referral_lead" ADD COLUMN "phone" text; \ No newline at end of file diff --git a/packages/coredb/drizzle/meta/0003_snapshot.json b/packages/coredb/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..2659e8c --- /dev/null +++ b/packages/coredb/drizzle/meta/0003_snapshot.json @@ -0,0 +1,3101 @@ +{ + "id": "5c8e5b0c-75cf-49ec-8f0f-c66a082794ee", + "prevId": "d0981716-8d67-4861-a365-e26c87d4367d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_id": { + "name": "referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_definition_id": { + "name": "event_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "event_product_id_idx": { + "name": "event_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_program_id_idx": { + "name": "event_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_participant_id_idx": { + "name": "event_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_referral_id_idx": { + "name": "event_referral_id_idx", + "columns": [ + { + "expression": "referral_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_definition_id_idx": { + "name": "event_definition_id_idx", + "columns": [ + { + "expression": "event_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_status_idx": { + "name": "event_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_created_at_idx": { + "name": "event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_product_id_product_id_fk": { + "name": "event_product_id_product_id_fk", + "tableFrom": "event", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_program_id_program_id_fk": { + "name": "event_program_id_program_id_fk", + "tableFrom": "event", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_participant_id_participant_id_fk": { + "name": "event_participant_id_participant_id_fk", + "tableFrom": "event", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_referral_id_referral_id_fk": { + "name": "event_referral_id_referral_id_fk", + "tableFrom": "event", + "tableTo": "referral", + "columnsFrom": [ + "referral_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_event_definition_id_event_definition_id_fk": { + "name": "event_event_definition_id_event_definition_id_fk", + "tableFrom": "event", + "tableTo": "event_definition", + "columnsFrom": [ + "event_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_definition": { + "name": "event_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_definition_type_unique": { + "name": "event_definition_type_unique", + "nullsNotDistinct": false, + "columns": [ + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handoff_token": { + "name": "handoff_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "handoff_token_hash_unique_idx": { + "name": "handoff_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handoff_token_lead_id_idx": { + "name": "handoff_token_lead_id_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handoff_token_expires_at_idx": { + "name": "handoff_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handoff_token_lead_id_referral_lead_id_fk": { + "name": "handoff_token_lead_id_referral_lead_id_fk", + "tableFrom": "handoff_token", + "tableTo": "referral_lead", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_org_id_fk": { + "name": "invitation_organization_id_org_id_fk", + "tableFrom": "invitation", + "tableTo": "org", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_product_id_product_id_fk": { + "name": "invitation_product_id_product_id_fk", + "tableFrom": "invitation", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_user": { + "name": "org_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_user_org_id_org_id_fk": { + "name": "org_user_org_id_org_id_fk", + "tableFrom": "org_user", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_user_user_id_user_id_fk": { + "name": "org_user_user_id_user_id_fk", + "tableFrom": "org_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant": { + "name": "participant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_product_id_product_id_fk": { + "name": "participant_product_id_product_id_fk", + "tableFrom": "participant", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "participant_product_id_external_id_unique": { + "name": "participant_product_id_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "product_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_type": { + "name": "app_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_case": { + "name": "use_case", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "product_org_id_org_id_fk": { + "name": "product_org_id_org_id_fk", + "tableFrom": "product", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_slug_unique": { + "name": "product_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_secret": { + "name": "product_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_secret_product_id_product_id_fk": { + "name": "product_secret_product_id_product_id_fk", + "tableFrom": "product_secret", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_user": { + "name": "product_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_user_product_id_product_id_fk": { + "name": "product_user_product_id_product_id_fk", + "tableFrom": "product_user", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_user_user_id_user_id_fk": { + "name": "product_user_user_id_user_id_fk", + "tableFrom": "product_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program": { + "name": "program", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_template_id": { + "name": "program_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "program_product_id_product_id_fk": { + "name": "program_product_id_product_id_fk", + "tableFrom": "program", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_user": { + "name": "program_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "program_user_program_id_program_id_fk": { + "name": "program_user_program_id_program_id_fk", + "tableFrom": "program_user", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "program_user_user_id_user_id_fk": { + "name": "program_user_user_id_user_id_fk", + "tableFrom": "program_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refcode": { + "name": "refcode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refcode_code_unique_idx": { + "name": "refcode_code_unique_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_participant_id_idx": { + "name": "refcode_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_program_id_idx": { + "name": "refcode_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_product_id_idx": { + "name": "refcode_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refcode_participant_id_participant_id_fk": { + "name": "refcode_participant_id_participant_id_fk", + "tableFrom": "refcode", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refcode_program_id_program_id_fk": { + "name": "refcode_program_id_program_id_fk", + "tableFrom": "refcode", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refcode_product_id_product_id_fk": { + "name": "refcode_product_id_product_id_fk", + "tableFrom": "refcode", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_participant_id_fk": { + "name": "referral_referrer_id_participant_id_fk", + "tableFrom": "referral", + "tableTo": "participant", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_lead": { + "name": "referral_lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_participant_id": { + "name": "referrer_participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refcode_id": { + "name": "refcode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_at": { + "name": "consent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "landing_url": { + "name": "landing_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm": { + "name": "utm", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "qualified_referral_id": { + "name": "qualified_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_by": { + "name": "matched_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "qualified_at": { + "name": "qualified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "referral_lead_email_idx": { + "name": "referral_lead_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_status_idx": { + "name": "referral_lead_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_code_idx": { + "name": "referral_lead_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_program_id_idx": { + "name": "referral_lead_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_referrer_idx": { + "name": "referral_lead_referrer_idx", + "columns": [ + { + "expression": "referrer_participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_created_at_idx": { + "name": "referral_lead_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_lead_program_id_program_id_fk": { + "name": "referral_lead_program_id_program_id_fk", + "tableFrom": "referral_lead", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_product_id_product_id_fk": { + "name": "referral_lead_product_id_product_id_fk", + "tableFrom": "referral_lead", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_referrer_participant_id_participant_id_fk": { + "name": "referral_lead_referrer_participant_id_participant_id_fk", + "tableFrom": "referral_lead", + "tableTo": "participant", + "columnsFrom": [ + "referrer_participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_refcode_id_refcode_id_fk": { + "name": "referral_lead_refcode_id_refcode_id_fk", + "tableFrom": "referral_lead", + "tableTo": "refcode", + "columnsFrom": [ + "refcode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "referral_lead_qualified_referral_id_referral_id_fk": { + "name": "referral_lead_qualified_referral_id_referral_id_fk", + "tableFrom": "referral_lead", + "tableTo": "referral", + "columnsFrom": [ + "qualified_referral_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reflink": { + "name": "reflink", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refcode_id": { + "name": "refcode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reflink_slug_product_unique_idx": { + "name": "reflink_slug_product_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_slug_idx": { + "name": "reflink_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_refcode_id_idx": { + "name": "reflink_refcode_id_idx", + "columns": [ + { + "expression": "refcode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_product_id_idx": { + "name": "reflink_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reflink_refcode_id_refcode_id_fk": { + "name": "reflink_refcode_id_refcode_id_fk", + "tableFrom": "reflink", + "tableTo": "refcode", + "columnsFrom": [ + "refcode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reflink_product_id_product_id_fk": { + "name": "reflink_product_id_product_id_fk", + "tableFrom": "reflink", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward": { + "name": "reward", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_rule_id": { + "name": "reward_rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_type": { + "name": "reward_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disbursed_at": { + "name": "disbursed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reward_participant_id_idx": { + "name": "reward_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_program_id_idx": { + "name": "reward_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_id_idx": { + "name": "reward_rule_id_idx", + "columns": [ + { + "expression": "reward_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_event_id_idx": { + "name": "reward_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_status_idx": { + "name": "reward_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_created_at_idx": { + "name": "reward_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_participant_id_participant_id_fk": { + "name": "reward_participant_id_participant_id_fk", + "tableFrom": "reward", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_program_id_program_id_fk": { + "name": "reward_program_id_program_id_fk", + "tableFrom": "reward", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_reward_rule_id_reward_rule_id_fk": { + "name": "reward_reward_rule_id_reward_rule_id_fk", + "tableFrom": "reward", + "tableTo": "reward_rule", + "columnsFrom": [ + "reward_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reward_event_id_event_id_fk": { + "name": "reward_event_id_event_id_fk", + "tableFrom": "reward", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_rule": { + "name": "reward_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": { + "reward_rule_program_id_idx": { + "name": "reward_rule_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_type_idx": { + "name": "reward_rule_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_is_active_idx": { + "name": "reward_rule_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_rule_program_id_program_id_fk": { + "name": "reward_rule_program_id_program_id_fk", + "tableFrom": "reward_rule", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_delivery": { + "name": "webhook_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 8 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_response_code": { + "name": "last_response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_delivery_event_id_unique_idx": { + "name": "webhook_delivery_event_id_unique_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_dedupe_key_unique_idx": { + "name": "webhook_delivery_dedupe_key_unique_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_status_idx": { + "name": "webhook_delivery_status_idx", + "columns": [ + { + "expression": "last_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_next_retry_at_idx": { + "name": "webhook_delivery_next_retry_at_idx", + "columns": [ + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_event_type_idx": { + "name": "webhook_delivery_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_delivery_endpoint_id_webhook_endpoint_id_fk": { + "name": "webhook_delivery_endpoint_id_webhook_endpoint_id_fk", + "tableFrom": "webhook_delivery", + "tableTo": "webhook_endpoint", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "webhook_delivery_product_id_product_id_fk": { + "name": "webhook_delivery_product_id_product_id_fk", + "tableFrom": "webhook_delivery", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_endpoint": { + "name": "webhook_endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secondary_secret": { + "name": "secondary_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"referral.created\",\"referral.qualified\"]'::jsonb" + } + }, + "indexes": { + "webhook_endpoint_product_id_idx": { + "name": "webhook_endpoint_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_endpoint_is_active_idx": { + "name": "webhook_endpoint_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoint_product_id_product_id_fk": { + "name": "webhook_endpoint_product_id_product_id_fk", + "tableFrom": "webhook_endpoint", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/coredb/drizzle/meta/0004_snapshot.json b/packages/coredb/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..e70ddf2 --- /dev/null +++ b/packages/coredb/drizzle/meta/0004_snapshot.json @@ -0,0 +1,3113 @@ +{ + "id": "ebd81cd8-cf53-4cac-91ce-6d70e1a55ec1", + "prevId": "5c8e5b0c-75cf-49ec-8f0f-c66a082794ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_user_id_user_id_fk": { + "name": "apikey_user_id_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_id": { + "name": "referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_definition_id": { + "name": "event_definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "event_product_id_idx": { + "name": "event_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_program_id_idx": { + "name": "event_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_participant_id_idx": { + "name": "event_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_referral_id_idx": { + "name": "event_referral_id_idx", + "columns": [ + { + "expression": "referral_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_definition_id_idx": { + "name": "event_definition_id_idx", + "columns": [ + { + "expression": "event_definition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_status_idx": { + "name": "event_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "event_created_at_idx": { + "name": "event_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "event_product_id_product_id_fk": { + "name": "event_product_id_product_id_fk", + "tableFrom": "event", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_program_id_program_id_fk": { + "name": "event_program_id_program_id_fk", + "tableFrom": "event", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "event_participant_id_participant_id_fk": { + "name": "event_participant_id_participant_id_fk", + "tableFrom": "event", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_referral_id_referral_id_fk": { + "name": "event_referral_id_referral_id_fk", + "tableFrom": "event", + "tableTo": "referral", + "columnsFrom": [ + "referral_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "event_event_definition_id_event_definition_id_fk": { + "name": "event_event_definition_id_event_definition_id_fk", + "tableFrom": "event", + "tableTo": "event_definition", + "columnsFrom": [ + "event_definition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.event_definition": { + "name": "event_definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "event_definition_type_unique": { + "name": "event_definition_type_unique", + "nullsNotDistinct": false, + "columns": [ + "type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.handoff_token": { + "name": "handoff_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_id": { + "name": "lead_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "handoff_token_hash_unique_idx": { + "name": "handoff_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handoff_token_lead_id_idx": { + "name": "handoff_token_lead_id_idx", + "columns": [ + { + "expression": "lead_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handoff_token_expires_at_idx": { + "name": "handoff_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "handoff_token_lead_id_referral_lead_id_fk": { + "name": "handoff_token_lead_id_referral_lead_id_fk", + "tableFrom": "handoff_token", + "tableTo": "referral_lead", + "columnsFrom": [ + "lead_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_org_id_fk": { + "name": "invitation_organization_id_org_id_fk", + "tableFrom": "invitation", + "tableTo": "org", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_product_id_product_id_fk": { + "name": "invitation_product_id_product_id_fk", + "tableFrom": "invitation", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org": { + "name": "org", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_slug_unique": { + "name": "org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_user": { + "name": "org_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "org_user_org_id_org_id_fk": { + "name": "org_user_org_id_org_id_fk", + "tableFrom": "org_user", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_user_user_id_user_id_fk": { + "name": "org_user_user_id_user_id_fk", + "tableFrom": "org_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.participant": { + "name": "participant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "participant_product_id_product_id_fk": { + "name": "participant_product_id_product_id_fk", + "tableFrom": "participant", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "participant_product_id_external_id_unique": { + "name": "participant_product_id_external_id_unique", + "nullsNotDistinct": false, + "columns": [ + "product_id", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_type": { + "name": "app_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "use_case": { + "name": "use_case", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "product_org_id_org_id_fk": { + "name": "product_org_id_org_id_fk", + "tableFrom": "product", + "tableTo": "org", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "product_slug_unique": { + "name": "product_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_secret": { + "name": "product_secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_secret_product_id_product_id_fk": { + "name": "product_secret_product_id_product_id_fk", + "tableFrom": "product_secret", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_user": { + "name": "product_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "product_user_product_id_product_id_fk": { + "name": "product_user_product_id_product_id_fk", + "tableFrom": "product_user", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_user_user_id_user_id_fk": { + "name": "product_user_user_id_user_id_fk", + "tableFrom": "product_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program": { + "name": "program", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_template_id": { + "name": "program_template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "program_product_id_product_id_fk": { + "name": "program_product_id_product_id_fk", + "tableFrom": "program", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_user": { + "name": "program_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "program_user_program_id_program_id_fk": { + "name": "program_user_program_id_program_id_fk", + "tableFrom": "program_user", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "program_user_user_id_user_id_fk": { + "name": "program_user_user_id_user_id_fk", + "tableFrom": "program_user", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refcode": { + "name": "refcode", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refcode_code_unique_idx": { + "name": "refcode_code_unique_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_participant_id_idx": { + "name": "refcode_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_program_id_idx": { + "name": "refcode_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refcode_product_id_idx": { + "name": "refcode_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refcode_participant_id_participant_id_fk": { + "name": "refcode_participant_id_participant_id_fk", + "tableFrom": "refcode", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refcode_program_id_program_id_fk": { + "name": "refcode_program_id_program_id_fk", + "tableFrom": "refcode", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "refcode_product_id_product_id_fk": { + "name": "refcode_product_id_product_id_fk", + "tableFrom": "refcode", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral": { + "name": "referral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "referrer_id": { + "name": "referrer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "referral_referrer_id_participant_id_fk": { + "name": "referral_referrer_id_participant_id_fk", + "tableFrom": "referral", + "tableTo": "participant", + "columnsFrom": [ + "referrer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_lead": { + "name": "referral_lead", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_participant_id": { + "name": "referrer_participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refcode_id": { + "name": "refcode_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_at": { + "name": "consent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "landing_url": { + "name": "landing_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm": { + "name": "utm", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "qualified_referral_id": { + "name": "qualified_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "matched_by": { + "name": "matched_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "qualified_at": { + "name": "qualified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "referral_lead_email_idx": { + "name": "referral_lead_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_status_idx": { + "name": "referral_lead_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_code_idx": { + "name": "referral_lead_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_program_id_idx": { + "name": "referral_lead_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_referrer_idx": { + "name": "referral_lead_referrer_idx", + "columns": [ + { + "expression": "referrer_participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "referral_lead_created_at_idx": { + "name": "referral_lead_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_lead_program_id_program_id_fk": { + "name": "referral_lead_program_id_program_id_fk", + "tableFrom": "referral_lead", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_product_id_product_id_fk": { + "name": "referral_lead_product_id_product_id_fk", + "tableFrom": "referral_lead", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_referrer_participant_id_participant_id_fk": { + "name": "referral_lead_referrer_participant_id_participant_id_fk", + "tableFrom": "referral_lead", + "tableTo": "participant", + "columnsFrom": [ + "referrer_participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_lead_refcode_id_refcode_id_fk": { + "name": "referral_lead_refcode_id_refcode_id_fk", + "tableFrom": "referral_lead", + "tableTo": "refcode", + "columnsFrom": [ + "refcode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "referral_lead_qualified_referral_id_referral_id_fk": { + "name": "referral_lead_qualified_referral_id_referral_id_fk", + "tableFrom": "referral_lead", + "tableTo": "referral", + "columnsFrom": [ + "qualified_referral_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reflink": { + "name": "reflink", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refcode_id": { + "name": "refcode_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reflink_slug_product_unique_idx": { + "name": "reflink_slug_product_unique_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_slug_idx": { + "name": "reflink_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_refcode_id_idx": { + "name": "reflink_refcode_id_idx", + "columns": [ + { + "expression": "refcode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reflink_product_id_idx": { + "name": "reflink_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reflink_refcode_id_refcode_id_fk": { + "name": "reflink_refcode_id_refcode_id_fk", + "tableFrom": "reflink", + "tableTo": "refcode", + "columnsFrom": [ + "refcode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reflink_product_id_product_id_fk": { + "name": "reflink_product_id_product_id_fk", + "tableFrom": "reflink", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward": { + "name": "reward", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_rule_id": { + "name": "reward_rule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_type": { + "name": "reward_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "disbursed_at": { + "name": "disbursed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "reward_participant_id_idx": { + "name": "reward_participant_id_idx", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_program_id_idx": { + "name": "reward_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_id_idx": { + "name": "reward_rule_id_idx", + "columns": [ + { + "expression": "reward_rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_event_id_idx": { + "name": "reward_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_status_idx": { + "name": "reward_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_created_at_idx": { + "name": "reward_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_participant_id_participant_id_fk": { + "name": "reward_participant_id_participant_id_fk", + "tableFrom": "reward", + "tableTo": "participant", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_program_id_program_id_fk": { + "name": "reward_program_id_program_id_fk", + "tableFrom": "reward", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reward_reward_rule_id_reward_rule_id_fk": { + "name": "reward_reward_rule_id_reward_rule_id_fk", + "tableFrom": "reward", + "tableTo": "reward_rule", + "columnsFrom": [ + "reward_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "reward_event_id_event_id_fk": { + "name": "reward_event_id_event_id_fk", + "tableFrom": "reward", + "tableTo": "event", + "columnsFrom": [ + "event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reward_rule": { + "name": "reward_rule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "program_id": { + "name": "program_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": { + "reward_rule_program_id_idx": { + "name": "reward_rule_program_id_idx", + "columns": [ + { + "expression": "program_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_type_idx": { + "name": "reward_rule_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reward_rule_is_active_idx": { + "name": "reward_rule_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reward_rule_program_id_program_id_fk": { + "name": "reward_rule_program_id_program_id_fk", + "tableFrom": "reward_rule", + "tableTo": "program", + "columnsFrom": [ + "program_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_delivery": { + "name": "webhook_delivery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 8 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_response_code": { + "name": "last_response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "webhook_delivery_event_id_unique_idx": { + "name": "webhook_delivery_event_id_unique_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_dedupe_key_unique_idx": { + "name": "webhook_delivery_dedupe_key_unique_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_status_idx": { + "name": "webhook_delivery_status_idx", + "columns": [ + { + "expression": "last_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_next_retry_at_idx": { + "name": "webhook_delivery_next_retry_at_idx", + "columns": [ + { + "expression": "next_retry_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_delivery_event_type_idx": { + "name": "webhook_delivery_event_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_delivery_endpoint_id_webhook_endpoint_id_fk": { + "name": "webhook_delivery_endpoint_id_webhook_endpoint_id_fk", + "tableFrom": "webhook_delivery", + "tableTo": "webhook_endpoint", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "webhook_delivery_product_id_product_id_fk": { + "name": "webhook_delivery_product_id_product_id_fk", + "tableFrom": "webhook_delivery", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_endpoint": { + "name": "webhook_endpoint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secondary_secret": { + "name": "secondary_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "event_types": { + "name": "event_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"referral.created\",\"referral.qualified\"]'::jsonb" + } + }, + "indexes": { + "webhook_endpoint_product_id_idx": { + "name": "webhook_endpoint_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_endpoint_is_active_idx": { + "name": "webhook_endpoint_is_active_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_endpoint_product_id_product_id_fk": { + "name": "webhook_endpoint_product_id_product_id_fk", + "tableFrom": "webhook_endpoint", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/coredb/drizzle/meta/_journal.json b/packages/coredb/drizzle/meta/_journal.json index e609e38..8c48dc2 100644 --- a/packages/coredb/drizzle/meta/_journal.json +++ b/packages/coredb/drizzle/meta/_journal.json @@ -22,6 +22,20 @@ "when": 1773868898031, "tag": "0002_charming_ikaris", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1786589697224, + "tag": "0003_bumpy_captain_flint", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1786602475224, + "tag": "0004_nostalgic_multiple_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/coredb/src/schema.ts b/packages/coredb/src/schema.ts index 350bc47..a709ca2 100644 --- a/packages/coredb/src/schema.ts +++ b/packages/coredb/src/schema.ts @@ -433,3 +433,225 @@ export const referralRelations = relations(referral, ({ one }) => ({ references: [participant.id], }), })); + +// =========================================================================== +// Welfie referral fork additions +// --------------------------------------------------------------------------- +// These tables are additive (never modify upstream tables) so the fork stays +// easy to rebase onto refrefhq/refref. They power the self-hosted referee +// form, the single-use signup hand-off, and the outbound webhook dispatcher. +// =========================================================================== + +/** + * A lead captured by the public referee form BEFORE the friend has a Welfie + * account. This is deliberately a new table rather than an extension of + * `referral`, because `referral.externalId` is NOT NULL (it is the referee's + * Welfie user id) and does not exist yet at form-submission time. When the + * friend later signs up, the lead is reconciled and a real `referral` row is + * created, linked back here via `qualifiedReferralId`. + */ +export const referralLead = pgTable( + "referral_lead", + { + ...baseFields("referralLead"), + programId: text("program_id") + .notNull() + .references(() => program.id, { onDelete: "cascade" }), + productId: text("product_id") + .notNull() + .references(() => product.id, { onDelete: "cascade" }), + // The referrer whose code was used. + referrerParticipantId: text("referrer_participant_id") + .notNull() + .references(() => participant.id, { onDelete: "cascade" }), + // Traceability back to the exact code row that was resolved. + refcodeId: text("refcode_id").references(() => refcode.id, { + onDelete: "set null", + }), + code: text("code").notNull(), + // Referee-supplied fields. + email: text("email").notNull(), + firstName: text("first_name"), + lastName: text("last_name"), + phone: text("phone"), + // Lifecycle: pending -> qualified | expired | rejected. + status: text("status").notNull().default("pending"), + emailVerified: boolean("email_verified").notNull().default(false), + // Consent is stored as versioned text + timestamp, not a bare boolean. + consentVersion: text("consent_version"), + consentAt: timestamp("consent_at"), + // Attribution context. + landingUrl: text("landing_url"), + utm: jsonb("utm").$type>(), + ip: text("ip"), + userAgent: text("user_agent"), + // Set when the lead is reconciled at signup. + qualifiedReferralId: text("qualified_referral_id").references( + () => referral.id, + { onDelete: "set null" }, + ), + // "handoff_token" | "email_reconciliation" — for attribution disputes. + matchedBy: text("matched_by"), + qualifiedAt: timestamp("qualified_at"), + }, + (table) => [ + index("referral_lead_email_idx").on(table.email), + index("referral_lead_status_idx").on(table.status), + index("referral_lead_code_idx").on(table.code), + index("referral_lead_program_id_idx").on(table.programId), + index("referral_lead_referrer_idx").on(table.referrerParticipantId), + index("referral_lead_created_at_idx").on(table.createdAt), + ], +); + +/** + * Single-use, short-lived opaque tokens for the signup redirect. We store only + * the SHA-256 hash of the token; the plaintext lives solely in the redirect URL + * and is exchanged server-side by welfie-backend. + */ +export const handoffToken = pgTable( + "handoff_token", + { + ...baseFields("handoffToken"), + tokenHash: text("token_hash").notNull(), + leadId: text("lead_id") + .notNull() + .references(() => referralLead.id, { onDelete: "cascade" }), + expiresAt: timestamp("expires_at").notNull(), + consumedAt: timestamp("consumed_at"), + }, + (table) => [ + uniqueIndex("handoff_token_hash_unique_idx").on(table.tokenHash), + index("handoff_token_lead_id_idx").on(table.leadId), + index("handoff_token_expires_at_idx").on(table.expiresAt), + ], +); + +/** + * Outbound webhook endpoint configuration, per product. `secret` is the active + * HMAC signing key; `secondarySecret` supports rotation with overlap (the + * receiver accepts both while a rotation is in flight). + */ +export const webhookEndpoint = pgTable( + "webhook_endpoint", + { + ...baseFields("webhookEndpoint"), + productId: text("product_id") + .notNull() + .references(() => product.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secret: text("secret").notNull(), + secondarySecret: text("secondary_secret"), + isActive: boolean("is_active").notNull().default(true), + // Subscribed event types, e.g. ["referral.created","referral.qualified"]. + eventTypes: jsonb("event_types") + .$type() + .notNull() + .default(["referral.created", "referral.qualified"]), + }, + (table) => [ + index("webhook_endpoint_product_id_idx").on(table.productId), + index("webhook_endpoint_is_active_idx").on(table.isActive), + ], +); + +/** + * Outbound delivery log AND dead-letter queue. One row per (event -> endpoint). + * `eventId` is the idempotency key that travels to the receiver; `dedupeKey` + * guards against enqueuing the same logical event twice (e.g. a double form + * submit). The webhook worker polls this table. + */ +export const webhookDelivery = pgTable( + "webhook_delivery", + { + ...baseFields("webhookDelivery"), + // Idempotency key sent to the receiver as the payload `event_id`. + eventId: text("event_id").notNull(), + // Optional application-level dedupe key (not sent to the receiver). + dedupeKey: text("dedupe_key"), + endpointId: text("endpoint_id").references(() => webhookEndpoint.id, { + onDelete: "set null", + }), + productId: text("product_id") + .notNull() + .references(() => product.id, { onDelete: "cascade" }), + eventType: text("event_type").notNull(), + payload: jsonb("payload").notNull().$type>(), + attempts: integer("attempts").notNull().default(0), + maxAttempts: integer("max_attempts").notNull().default(8), + // pending | delivering | delivered | failed | dead_letter + lastStatus: text("last_status").notNull().default("pending"), + lastError: text("last_error"), + lastResponseCode: integer("last_response_code"), + nextRetryAt: timestamp("next_retry_at").defaultNow(), + deliveredAt: timestamp("delivered_at"), + }, + (table) => [ + uniqueIndex("webhook_delivery_event_id_unique_idx").on(table.eventId), + uniqueIndex("webhook_delivery_dedupe_key_unique_idx").on(table.dedupeKey), + index("webhook_delivery_status_idx").on(table.lastStatus), + index("webhook_delivery_next_retry_at_idx").on(table.nextRetryAt), + index("webhook_delivery_event_type_idx").on(table.eventType), + ], +); + +// --- Relations for the fork tables --- + +export const referralLeadRelations = relations( + referralLead, + ({ one, many }) => ({ + program: one(program, { + fields: [referralLead.programId], + references: [program.id], + }), + product: one(product, { + fields: [referralLead.productId], + references: [product.id], + }), + referrer: one(participant, { + fields: [referralLead.referrerParticipantId], + references: [participant.id], + }), + refcode: one(refcode, { + fields: [referralLead.refcodeId], + references: [refcode.id], + }), + qualifiedReferral: one(referral, { + fields: [referralLead.qualifiedReferralId], + references: [referral.id], + }), + handoffTokens: many(handoffToken), + }), +); + +export const handoffTokenRelations = relations(handoffToken, ({ one }) => ({ + lead: one(referralLead, { + fields: [handoffToken.leadId], + references: [referralLead.id], + }), +})); + +export const webhookEndpointRelations = relations( + webhookEndpoint, + ({ one, many }) => ({ + product: one(product, { + fields: [webhookEndpoint.productId], + references: [product.id], + }), + deliveries: many(webhookDelivery), + }), +); + +export const webhookDeliveryRelations = relations( + webhookDelivery, + ({ one }) => ({ + endpoint: one(webhookEndpoint, { + fields: [webhookDelivery.endpointId], + references: [webhookEndpoint.id], + }), + product: one(product, { + fields: [webhookDelivery.productId], + references: [product.id], + }), + }), +); diff --git a/packages/id/src/index.test.ts b/packages/id/src/index.test.ts index d519a57..bb729b1 100644 --- a/packages/id/src/index.test.ts +++ b/packages/id/src/index.test.ts @@ -58,6 +58,12 @@ describe("@refref/id", () => { ["event", "evt"], ["org", "org"], ["orgUser", "ou"], + ["reflink", "rl"], + ["referralLead", "rld"], + ["handoffToken", "hto"], + ["webhookEndpoint", "whe"], + ["webhookDelivery", "whd"], + ["webhookEvent", "wev"], ]; it.each(entityTests)( @@ -97,9 +103,9 @@ describe("@refref/id", () => { expect(validTypes).toContain("program"); }); - it("should return all 21 entity types", () => { + it("should return all 27 entity types", () => { const validTypes = getValidEntityTypes(); - expect(validTypes.length).toBe(21); + expect(validTypes.length).toBe(27); }); }); diff --git a/packages/id/src/index.ts b/packages/id/src/index.ts index 72bd3d0..542f303 100644 --- a/packages/id/src/index.ts +++ b/packages/id/src/index.ts @@ -26,6 +26,12 @@ const ENTITY_PREFIXES = { reflink: "rl", referral: "ref", event: "evt", + // Welfie referral fork additions + referralLead: "rld", + handoffToken: "hto", + webhookEndpoint: "whe", + webhookDelivery: "whd", + webhookEvent: "wev", } as const; /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index eccbce1..eae6301 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,6 +13,7 @@ export * from "./widget-config"; export * from "./widget-css-defaults"; export * from "./event-config"; export * from "./reward-config"; +export * from "./webhook-config"; export const referralLinkSchema = z.object({ code: z.string(), diff --git a/packages/types/src/webhook-config.ts b/packages/types/src/webhook-config.ts new file mode 100644 index 0000000..4ae2981 --- /dev/null +++ b/packages/types/src/webhook-config.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +/** + * Outbound webhook contract for the Welfie referral fork. + * + * Two events are emitted: + * - `referral.created` when the referee submits the public form. + * - `referral.qualified` when that referee later signs up in Welfie. + * + * The receiver (welfie-backend) verifies the HMAC signature and timestamp + * BEFORE parsing the body, then upserts on `event_id` (idempotency key). + */ + +export const WEBHOOK_EVENT_TYPES = [ + "referral.created", + "referral.qualified", +] as const; + +export const webhookEventTypeSchema = z.enum(WEBHOOK_EVENT_TYPES); +export type WebhookEventType = z.infer; + +/** Signature headers sent on every delivery. */ +export const WEBHOOK_SIGNATURE_HEADER = "x-refref-signature"; +export const WEBHOOK_TIMESTAMP_HEADER = "x-refref-timestamp"; +export const WEBHOOK_EVENT_ID_HEADER = "x-refref-event-id"; +export const WEBHOOK_EVENT_TYPE_HEADER = "x-refref-event-type"; + +/** Reject deliveries whose timestamp is skewed beyond this many seconds. */ +export const WEBHOOK_MAX_SKEW_SECONDS = 120; + +const consentSchema = z.object({ + version: z.string(), + accepted_at: z.string().datetime(), +}); + +const attributionSchema = z.object({ + landing_url: z.string(), + utm: z.record(z.string(), z.string()).default({}), + ip: z.string().nullable().optional(), + user_agent: z.string().nullable().optional(), +}); + +export const referralCreatedPayloadSchema = z.object({ + event: z.literal("referral.created"), + event_id: z.string(), + occurred_at: z.string().datetime(), + program_id: z.string(), + referral: z.object({ + id: z.string(), + code: z.string(), + status: z.literal("pending"), + }), + referrer: z.object({ + participant_id: z.string(), + external_id: z.string().nullable(), + email: z.string().nullable().optional(), + name: z.string().nullable().optional(), + }), + referee: z.object({ + email: z.string().email(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + phone: z.string().nullable().optional(), + email_verified: z.boolean(), + form_fields: z.record(z.string(), z.unknown()).default({}), + }), + consent: consentSchema, + attribution: attributionSchema, +}); +export type ReferralCreatedPayload = z.infer< + typeof referralCreatedPayloadSchema +>; + +export const referralQualifiedPayloadSchema = z.object({ + event: z.literal("referral.qualified"), + event_id: z.string(), + occurred_at: z.string().datetime(), + program_id: z.string(), + referral: z.object({ + id: z.string(), + code: z.string(), + status: z.literal("qualified"), + }), + referee: z.object({ + email: z.string().email(), + external_id: z.string().nullable(), + }), + reward: z + .object({ + id: z.string().nullable(), + type: z.string(), + amount: z.number(), + currency: z.string(), + status: z.string(), + }) + .nullable(), + // "handoff_token" | "email_reconciliation" + matched_by: z.string(), +}); +export type ReferralQualifiedPayload = z.infer< + typeof referralQualifiedPayloadSchema +>; + +export const webhookPayloadSchema = z.discriminatedUnion("event", [ + referralCreatedPayloadSchema, + referralQualifiedPayloadSchema, +]); +export type WebhookPayload = z.infer; diff --git a/packages/webhooks/package.json b/packages/webhooks/package.json new file mode 100644 index 0000000..ceb4bdc --- /dev/null +++ b/packages/webhooks/package.json @@ -0,0 +1,37 @@ +{ + "name": "@refref/webhooks", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "type:check": "tsc --noEmit", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@refref/coredb": "workspace:*", + "@refref/id": "workspace:*", + "@refref/types": "workspace:*", + "drizzle-orm": "^0.44.7" + }, + "devDependencies": { + "@refref/typescript-config": "workspace:*", + "@types/node": "^22.10.2", + "typescript": "^5.7.2", + "vitest": "^2.1.9" + } +} diff --git a/packages/webhooks/src/dispatcher.ts b/packages/webhooks/src/dispatcher.ts new file mode 100644 index 0000000..70d495e --- /dev/null +++ b/packages/webhooks/src/dispatcher.ts @@ -0,0 +1,298 @@ +import { schema, type DBType } from "@refref/coredb"; +import { and, eq, inArray, lte, sql } from "drizzle-orm"; +import { + WEBHOOK_EVENT_ID_HEADER, + WEBHOOK_EVENT_TYPE_HEADER, + WEBHOOK_SIGNATURE_HEADER, + WEBHOOK_TIMESTAMP_HEADER, +} from "@refref/types"; +import { signPayload } from "./signing.js"; + +const { webhookDelivery, webhookEndpoint } = schema; + +export interface WorkerOptions { + /** Rows to claim per tick. Default 20. */ + batchSize?: number; + /** Base backoff in ms (attempt 1). Default 30_000 (30s). */ + baseBackoffMs?: number; + /** Max backoff in ms. Default 6h. */ + maxBackoffMs?: number; + /** Per-request timeout in ms. Default 10_000. */ + requestTimeoutMs?: number; + /** Injectable clock for tests. */ + now?: () => Date; + /** Injectable fetch for tests. */ + fetchImpl?: typeof fetch; + /** Optional structured logger. */ + logger?: { + info: (o: unknown, m?: string) => void; + error: (o: unknown, m?: string) => void; + }; +} + +const DEFAULTS = { + batchSize: 20, + baseBackoffMs: 30_000, + maxBackoffMs: 6 * 60 * 60 * 1000, + requestTimeoutMs: 10_000, +}; + +/** Exponential backoff with light jitter. attempt is 1-based. */ +export function computeBackoffMs( + attempt: number, + baseMs: number, + maxMs: number, +): number { + const raw = baseMs * Math.pow(2, Math.max(0, attempt - 1)); + const capped = Math.min(raw, maxMs); + // +/- 15% jitter to avoid thundering herds on a recovering endpoint. + const jitter = capped * 0.15 * (Math.random() * 2 - 1); + return Math.max(0, Math.floor(capped + jitter)); +} + +/** + * Claim up to `batchSize` due deliveries by flipping them to `delivering` in a + * single UPDATE, so a second worker instance cannot pick the same rows. Returns + * the claimed rows. + */ +async function claimDue(db: DBType, batchSize: number, now: Date) { + // Subquery selects candidate ids; UPDATE ... WHERE id IN (...) RETURNING. + const candidates = await db + .select({ id: webhookDelivery.id }) + .from(webhookDelivery) + .where( + and( + inArray(webhookDelivery.lastStatus, ["pending", "failed"]), + lte(webhookDelivery.nextRetryAt, now), + lt(webhookDelivery.attempts, webhookDelivery.maxAttempts), + ), + ) + .orderBy(webhookDelivery.nextRetryAt) + .limit(batchSize); + + const ids = candidates.map((c) => c.id); + if (ids.length === 0) return []; + + const claimed = await db + .update(webhookDelivery) + .set({ lastStatus: "delivering", updatedAt: now }) + .where( + and( + inArray(webhookDelivery.id, ids), + inArray(webhookDelivery.lastStatus, ["pending", "failed"]), + ), + ) + .returning(); + + return claimed; +} + +// drizzle helper: column < column +function lt(a: unknown, b: unknown) { + return sql`${a} < ${b}`; +} + +/** + * Deliver a single already-claimed row. Exported for tests and manual replay. + */ +export async function deliverOne( + db: DBType, + deliveryId: string, + options: WorkerOptions = {}, +): Promise<"delivered" | "failed" | "dead_letter" | "skipped"> { + const opts = { ...DEFAULTS, ...options }; + const now = options.now ?? (() => new Date()); + const doFetch = options.fetchImpl ?? fetch; + + const [row] = await db + .select() + .from(webhookDelivery) + .where(eq(webhookDelivery.id, deliveryId)) + .limit(1); + if (!row) return "skipped"; + + const [endpoint] = row.endpointId + ? await db + .select() + .from(webhookEndpoint) + .where(eq(webhookEndpoint.id, row.endpointId)) + .limit(1) + : [undefined]; + + const attemptNo = row.attempts + 1; + + // Endpoint gone or inactive -> dead-letter immediately, do not spin. + if (!endpoint || !endpoint.isActive) { + await db + .update(webhookDelivery) + .set({ + lastStatus: "dead_letter", + lastError: "endpoint missing or inactive", + attempts: attemptNo, + updatedAt: now(), + }) + .where(eq(webhookDelivery.id, deliveryId)); + return "dead_letter"; + } + + const rawBody = JSON.stringify(row.payload); + const timestamp = Math.floor(now().getTime() / 1000).toString(); + const signature = signPayload(endpoint.secret, timestamp, rawBody); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), opts.requestTimeoutMs); + + let responseCode: number | null = null; + let errorText: string | null = null; + let ok = false; + + try { + const res = await doFetch(endpoint.url, { + method: "POST", + headers: { + "content-type": "application/json", + [WEBHOOK_SIGNATURE_HEADER]: signature, + [WEBHOOK_TIMESTAMP_HEADER]: timestamp, + [WEBHOOK_EVENT_ID_HEADER]: row.eventId, + [WEBHOOK_EVENT_TYPE_HEADER]: row.eventType, + }, + body: rawBody, + signal: controller.signal, + }); + responseCode = res.status; + ok = res.status >= 200 && res.status < 300; + if (!ok) { + errorText = `non-2xx status ${res.status}`; + } + } catch (err) { + errorText = err instanceof Error ? err.message : String(err); + } finally { + clearTimeout(timer); + } + + if (ok) { + await db + .update(webhookDelivery) + .set({ + lastStatus: "delivered", + lastResponseCode: responseCode, + lastError: null, + attempts: attemptNo, + deliveredAt: now(), + nextRetryAt: null, + updatedAt: now(), + }) + .where(eq(webhookDelivery.id, deliveryId)); + options.logger?.info( + { deliveryId, eventId: row.eventId, attemptNo }, + "webhook delivered", + ); + return "delivered"; + } + + // Failure path: schedule retry or dead-letter. + const exhausted = attemptNo >= row.maxAttempts; + const nextRetryAt = exhausted + ? null + : new Date( + now().getTime() + + computeBackoffMs(attemptNo, opts.baseBackoffMs, opts.maxBackoffMs), + ); + + await db + .update(webhookDelivery) + .set({ + lastStatus: exhausted ? "dead_letter" : "failed", + lastResponseCode: responseCode, + lastError: errorText, + attempts: attemptNo, + nextRetryAt, + updatedAt: now(), + }) + .where(eq(webhookDelivery.id, deliveryId)); + + options.logger?.error( + { deliveryId, eventId: row.eventId, attemptNo, errorText, exhausted }, + "webhook delivery failed", + ); + + return exhausted ? "dead_letter" : "failed"; +} + +/** + * Process one batch of due deliveries. Returns how many rows were attempted. + * Call this on an interval, or once from a cron/queue consumer. + */ +export async function runWebhookWorkerOnce( + db: DBType, + options: WorkerOptions = {}, +): Promise { + const opts = { ...DEFAULTS, ...options }; + const now = options.now ?? (() => new Date()); + const claimed = await claimDue(db, opts.batchSize, now()); + for (const row of claimed) { + await deliverOne(db, row.id, options); + } + return claimed.length; +} + +/** + * Long-running poller. Returns a stop() function. Intended to run inside the + * API process (env-gated) or as a standalone worker entrypoint. + */ +export function runWebhookWorker( + db: DBType, + intervalMs = 5_000, + options: WorkerOptions = {}, +): () => void { + let stopped = false; + let timer: ReturnType | undefined; + + const tick = async () => { + if (stopped) return; + try { + await runWebhookWorkerOnce(db, options); + } catch (err) { + options.logger?.error({ err }, "webhook worker tick failed"); + } finally { + if (!stopped) timer = setTimeout(tick, intervalMs); + } + }; + + timer = setTimeout(tick, intervalMs); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; +} + +/** + * Reset a failed/dead-letter delivery so the worker will attempt it again. + * This is the replay path required for the dead-letter queue. + */ +export async function replayDelivery( + db: DBType, + deliveryId: string, + { resetAttempts = false }: { resetAttempts?: boolean } = {}, +): Promise { + const set: Record = { + lastStatus: "pending", + nextRetryAt: new Date(), + lastError: null, + updatedAt: new Date(), + }; + if (resetAttempts) set.attempts = 0; + + const rows = await db + .update(webhookDelivery) + .set(set) + .where( + and( + eq(webhookDelivery.id, deliveryId), + inArray(webhookDelivery.lastStatus, ["failed", "dead_letter"]), + ), + ) + .returning({ id: webhookDelivery.id }); + + return rows.length > 0; +} diff --git a/packages/webhooks/src/enqueue.ts b/packages/webhooks/src/enqueue.ts new file mode 100644 index 0000000..929d984 --- /dev/null +++ b/packages/webhooks/src/enqueue.ts @@ -0,0 +1,90 @@ +import { schema, type DBType } from "@refref/coredb"; +import { createId } from "@refref/id"; +import { eq, and } from "drizzle-orm"; +import type { WebhookEventType } from "@refref/types"; + +const { webhookEndpoint, webhookDelivery } = schema; + +export interface EnqueueWebhookInput { + productId: string; + eventType: WebhookEventType; + /** The payload MINUS the transport-level `event_id` — that is generated here. */ + payload: Record; + /** + * Optional application-level idempotency key. If a delivery with the same + * dedupeKey already exists, enqueue is a no-op. Use this to make a double + * form-submit or a retried signup call harmless. + */ + dedupeKey?: string; +} + +export interface EnqueueResult { + enqueued: number; + eventIds: string[]; + skipped: boolean; +} + +/** + * Enqueue an outbound webhook for every active endpoint on the product that is + * subscribed to this event type. Writes one `webhook_delivery` row per endpoint + * with `lastStatus = "pending"` and `nextRetryAt = now`, to be picked up by the + * worker. The call is asynchronous by contract: it never performs the HTTP + * request inline, so a slow downstream can never turn into a form/API timeout. + */ +export async function enqueueWebhook( + db: DBType, + input: EnqueueWebhookInput, +): Promise { + const endpoints = await db + .select() + .from(webhookEndpoint) + .where( + and( + eq(webhookEndpoint.productId, input.productId), + eq(webhookEndpoint.isActive, true), + ), + ); + + const subscribed = endpoints.filter((e) => + (e.eventTypes ?? []).includes(input.eventType), + ); + + if (subscribed.length === 0) { + return { enqueued: 0, eventIds: [], skipped: true }; + } + + const eventIds: string[] = []; + let enqueued = 0; + + for (const endpoint of subscribed) { + const eventId = createId("webhookEvent"); + // Stamp the transport-level event_id into the payload so what we persist is + // byte-for-byte what we will POST later. + const payload = { ...input.payload, event_id: eventId }; + const dedupeKey = input.dedupeKey + ? `${endpoint.id}:${input.dedupeKey}` + : null; + + const [row] = await db + .insert(webhookDelivery) + .values({ + eventId, + dedupeKey, + endpointId: endpoint.id, + productId: input.productId, + eventType: input.eventType, + payload, + lastStatus: "pending", + nextRetryAt: new Date(), + }) + .onConflictDoNothing({ target: webhookDelivery.dedupeKey }) + .returning({ id: webhookDelivery.id, eventId: webhookDelivery.eventId }); + + if (row) { + enqueued += 1; + eventIds.push(row.eventId); + } + } + + return { enqueued, eventIds, skipped: false }; +} diff --git a/packages/webhooks/src/index.ts b/packages/webhooks/src/index.ts new file mode 100644 index 0000000..01bc0d6 --- /dev/null +++ b/packages/webhooks/src/index.ts @@ -0,0 +1,24 @@ +export { + buildSigningString, + signPayload, + verifySignature, + constantTimeEqual, + generateOpaqueToken, + type VerifyOptions, + type VerifyResult, +} from "./signing.js"; + +export { + enqueueWebhook, + type EnqueueWebhookInput, + type EnqueueResult, +} from "./enqueue.js"; + +export { + deliverOne, + runWebhookWorkerOnce, + runWebhookWorker, + replayDelivery, + computeBackoffMs, + type WorkerOptions, +} from "./dispatcher.js"; diff --git a/packages/webhooks/src/signing.test.ts b/packages/webhooks/src/signing.test.ts new file mode 100644 index 0000000..82ac86f --- /dev/null +++ b/packages/webhooks/src/signing.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + signPayload, + verifySignature, + constantTimeEqual, + generateOpaqueToken, +} from "./signing.js"; +import { computeBackoffMs } from "./dispatcher.js"; + +const SECRET = "whsec_primary_key"; +const OTHER = "whsec_rotated_key"; +const BODY = JSON.stringify({ event: "referral.created", event_id: "wev_1" }); + +describe("signPayload / verifySignature", () => { + it("round-trips a valid signature", () => { + const ts = "1000000000"; + const sig = signPayload(SECRET, ts, BODY); + const res = verifySignature({ + secrets: [SECRET], + signature: sig, + timestamp: ts, + rawBody: BODY, + nowSeconds: 1000000000, + }); + expect(res.valid).toBe(true); + }); + + it("rejects a tampered body", () => { + const ts = "1000000000"; + const sig = signPayload(SECRET, ts, BODY); + const res = verifySignature({ + secrets: [SECRET], + signature: sig, + timestamp: ts, + rawBody: BODY + " ", + nowSeconds: 1000000000, + }); + expect(res).toEqual({ valid: false, reason: "signature" }); + }); + + it("rejects a stale timestamp beyond skew", () => { + const ts = "1000000000"; + const sig = signPayload(SECRET, ts, BODY); + const res = verifySignature({ + secrets: [SECRET], + signature: sig, + timestamp: ts, + rawBody: BODY, + nowSeconds: 1000000000 + 300, // 5 min later, default skew 120s + }); + expect(res).toEqual({ valid: false, reason: "skew" }); + }); + + it("accepts either secret during rotation overlap", () => { + const ts = "1000000000"; + // Signed with the rotated (new) key... + const sig = signPayload(OTHER, ts, BODY); + // ...receiver still lists both old and new as acceptable. + const res = verifySignature({ + secrets: [SECRET, OTHER], + signature: sig, + timestamp: ts, + rawBody: BODY, + nowSeconds: 1000000000, + }); + expect(res.valid).toBe(true); + }); + + it("flags missing headers", () => { + const res = verifySignature({ + secrets: [SECRET], + signature: "", + timestamp: "", + rawBody: BODY, + nowSeconds: 1000000000, + }); + expect(res).toEqual({ valid: false, reason: "missing" }); + }); +}); + +describe("constantTimeEqual", () => { + it("is true for equal strings and false otherwise", () => { + expect(constantTimeEqual("abc", "abc")).toBe(true); + expect(constantTimeEqual("abc", "abd")).toBe(false); + expect(constantTimeEqual("abc", "abcd")).toBe(false); + }); +}); + +describe("generateOpaqueToken", () => { + it("produces url-safe, unique tokens", () => { + const a = generateOpaqueToken(); + const b = generateOpaqueToken(); + expect(a).not.toEqual(b); + expect(a).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); + +describe("computeBackoffMs", () => { + it("grows roughly exponentially and stays capped", () => { + const base = 1000; + const max = 60_000; + const a1 = computeBackoffMs(1, base, max); + const a3 = computeBackoffMs(3, base, max); + const a20 = computeBackoffMs(20, base, max); + // attempt 1 ~ 1000ms (+/-15%), attempt 3 ~ 4000ms + expect(a1).toBeGreaterThan(800); + expect(a1).toBeLessThan(1200); + expect(a3).toBeGreaterThan(3000); + // capped near max (+/-15% jitter) + expect(a20).toBeLessThanOrEqual(Math.ceil(max * 1.15)); + }); +}); diff --git a/packages/webhooks/src/signing.ts b/packages/webhooks/src/signing.ts new file mode 100644 index 0000000..6290926 --- /dev/null +++ b/packages/webhooks/src/signing.ts @@ -0,0 +1,118 @@ +import { createHmac, timingSafeEqual, randomBytes } from "node:crypto"; +import { + WEBHOOK_MAX_SKEW_SECONDS, + WEBHOOK_SIGNATURE_HEADER, + WEBHOOK_TIMESTAMP_HEADER, +} from "@refref/types"; + +/** + * Signature scheme + * ---------------- + * We compute HMAC-SHA256 over `${timestamp}.${rawBody}` using the shared + * secret, and send it as: + * + * X-Refref-Signature: sha256= + * X-Refref-Timestamp: + * + * Binding the timestamp into the signed string is what makes replays + * detectable: an attacker cannot reuse an old signature with a fresh timestamp, + * and the receiver additionally rejects timestamps skewed beyond + * WEBHOOK_MAX_SKEW_SECONDS. The receiver MUST verify the signature over the + * exact raw bytes it received, BEFORE JSON parsing. + */ + +const PREFIX = "sha256="; + +/** Build the exact string that gets HMAC'd. */ +export function buildSigningString(timestamp: string, rawBody: string): string { + return `${timestamp}.${rawBody}`; +} + +/** Compute the `sha256=` signature for a body + timestamp with one secret. */ +export function signPayload( + secret: string, + timestamp: string, + rawBody: string, +): string { + const mac = createHmac("sha256", secret) + .update(buildSigningString(timestamp, rawBody), "utf8") + .digest("hex"); + return `${PREFIX}${mac}`; +} + +/** Constant-time comparison of two signature strings of arbitrary length. */ +export function constantTimeEqual(a: string, b: string): boolean { + const ab = Buffer.from(a, "utf8"); + const bb = Buffer.from(b, "utf8"); + // timingSafeEqual throws on length mismatch, so guard first — but keep the + // work constant by comparing against a same-length buffer either way. + if (ab.length !== bb.length) { + // Compare a against itself to burn roughly equivalent time, then fail. + timingSafeEqual(ab, ab); + return false; + } + return timingSafeEqual(ab, bb); +} + +export interface VerifyOptions { + /** All acceptable secrets. Pass [primary, secondary] during key rotation. */ + secrets: string[]; + /** Header value from X-Refref-Signature. */ + signature: string; + /** Header value from X-Refref-Timestamp (unix seconds as string). */ + timestamp: string; + /** The exact raw request body bytes, as a string. */ + rawBody: string; + /** Current time in unix seconds. Defaults to Date.now(). Injectable for tests. */ + nowSeconds?: number; + /** Max allowed clock skew. Defaults to WEBHOOK_MAX_SKEW_SECONDS. */ + maxSkewSeconds?: number; +} + +export type VerifyResult = + | { valid: true } + | { valid: false; reason: "missing" | "skew" | "signature" }; + +/** + * Verify an inbound webhook. This is the reference implementation the receiver + * (welfie-backend) should mirror. It accepts MULTIPLE secrets so a key rotation + * can overlap: sign new deliveries with the new key while still accepting the + * old one until every in-flight delivery has drained. + */ +export function verifySignature(opts: VerifyOptions): VerifyResult { + const { + secrets, + signature, + timestamp, + rawBody, + maxSkewSeconds = WEBHOOK_MAX_SKEW_SECONDS, + } = opts; + + if (!signature || !timestamp || secrets.length === 0) { + return { valid: false, reason: "missing" }; + } + + const ts = Number.parseInt(timestamp, 10); + if (!Number.isFinite(ts)) { + return { valid: false, reason: "skew" }; + } + const now = + opts.nowSeconds ?? Math.floor(Date.now() / 1000); + if (Math.abs(now - ts) > maxSkewSeconds) { + return { valid: false, reason: "skew" }; + } + + // Any accepted secret producing a matching signature is a pass. + for (const secret of secrets) { + const expected = signPayload(secret, timestamp, rawBody); + if (constantTimeEqual(expected, signature)) { + return { valid: true }; + } + } + return { valid: false, reason: "signature" }; +} + +/** Generate an opaque, URL-safe hand-off token (not a signing secret). */ +export function generateOpaqueToken(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} diff --git a/packages/webhooks/tsconfig.json b/packages/webhooks/tsconfig.json new file mode 100644 index 0000000..5ae8e1a --- /dev/null +++ b/packages/webhooks/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@refref/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/webhooks/vitest.config.ts b/packages/webhooks/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/packages/webhooks/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48231e1..4d97383 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ importers: '@refref/utils': specifier: workspace:* version: link:../../packages/utils + '@refref/webhooks': + specifier: workspace:* + version: link:../../packages/webhooks '@tsndr/cloudflare-worker-jwt': specifier: ^3.2.0 version: 3.2.0 @@ -201,6 +204,9 @@ importers: '@fastify/cors': specifier: ^10.0.1 version: 10.1.0 + '@fastify/formbody': + specifier: ^8.0.0 + version: 8.0.2 '@fastify/rate-limit': specifier: ^10.3.0 version: 10.3.0 @@ -213,6 +219,9 @@ importers: '@refref/utils': specifier: workspace:* version: link:../../packages/utils + '@refref/webhooks': + specifier: workspace:* + version: link:../../packages/webhooks dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -225,6 +234,9 @@ importers: postgres: specifier: ^3.4.5 version: 3.4.7 + zod: + specifier: ^4.0.17 + version: 4.1.12 devDependencies: '@types/node': specifier: ^22.10.2 @@ -444,7 +456,7 @@ importers: version: 0.4.6(react-dom@19.2.0(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - version: 2.8.5(next@16.0.7(@babel/core@7.28.5)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.5(next@16.0.7(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3) plugins: specifier: link:better-auth/client/plugins version: link:better-auth/client/plugins @@ -960,7 +972,7 @@ importers: version: 0.4.6(react-dom@19.2.0(react@19.2.3))(react@19.2.3) nuqs: specifier: 2.8.5 - version: 2.8.5(next@16.0.7(@babel/core@7.28.5)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3) + version: 2.8.5(next@16.0.7(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.4))(@types/react@19.2.4)(react-dom@19.2.0(react@19.2.3))(react@19.2.3) @@ -1057,6 +1069,34 @@ importers: specifier: ^2.1.9 version: 2.1.9(@types/node@22.19.1)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1) + packages/webhooks: + dependencies: + '@refref/coredb': + specifier: workspace:* + version: link:../coredb + '@refref/id': + specifier: workspace:* + version: link:../id + '@refref/types': + specifier: workspace:* + version: link:../types + drizzle-orm: + specifier: ^0.44.7 + version: 0.44.7(@cloudflare/workers-types@4.20251113.0)(@prisma/client@5.22.0(prisma@5.22.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.4.1)(kysely@0.28.8)(postgres@3.4.7)(prisma@5.22.0) + devDependencies: + '@refref/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: ^22.10.2 + version: 22.19.1 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.1)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1) + packages/widget: dependencies: '@refref/types': @@ -2761,6 +2801,9 @@ packages: '@fastify/fast-json-stringify-compiler@5.0.3': resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + '@fastify/formbody@8.0.2': + resolution: {integrity: sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==} + '@fastify/forwarded@3.0.1': resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} @@ -12214,6 +12257,11 @@ snapshots: dependencies: fast-json-stringify: 6.1.1 + '@fastify/formbody@8.0.2': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 5.1.0 + '@fastify/forwarded@3.0.1': {} '@fastify/merge-json-schemas@0.2.1': @@ -16005,7 +16053,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@20.19.25)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1) + vitest: 2.1.9(@types/node@22.19.1)(@vitest/ui@2.1.9)(happy-dom@15.11.7)(jsdom@25.0.1)(lightningcss@1.30.2)(terser@5.44.1) '@vitest/utils@2.1.9': dependencies: @@ -19087,7 +19135,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.5(next@16.0.7(@babel/core@7.28.5)(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3): + nuqs@2.8.5(next@16.0.7(@playwright/test@1.56.1)(react-dom@19.2.0(react@19.2.3))(react@19.2.3))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3