diff --git a/docs/plans/invite-code-multi-use.md b/docs/plans/invite-code-multi-use.md index dffd6854..b48b2031 100644 --- a/docs/plans/invite-code-multi-use.md +++ b/docs/plans/invite-code-multi-use.md @@ -1,8 +1,7 @@ # Multi-Use Invite Codes with Viral Redemption > **Status**: Draft -> **Branch**: `feat/invite-code-multi-use` -> **Parent PR**: #183 (Invite code gating for Instant Assistant) +> **Branch**: `feat/invite-code-multi-use` > **Parent PR**: #183 (Invite code gating for Instant Assistant) > **Created**: 2026-04-01 ## Overview @@ -33,23 +32,23 @@ Expand the invite code system so that codes can be redeemed a configurable numbe ### Schema Changes to `InviteCode` -| Column | Type | Default | Notes | -| ------------------ | -------- | ------- | -------------------------------------------------- | -| `name` | String? | null | Optional human-readable label for the code | -| `maxRedemptions` | Int | 1 | How many times this code can be redeemed | -| `redemptionCount` | Int | 0 | How many times this code has been redeemed so far | -| `parentCodeId` | UUID? | null | FK → InviteCode.id — the code that was redeemed to generate this one | +| Column | Type | Default | Notes | +| ----------------- | ------- | ------- | -------------------------------------------------------------------- | +| `name` | String? | null | Optional human-readable label for the code | +| `maxRedemptions` | Int | 1 | How many times this code can be redeemed | +| `redemptionCount` | Int | 0 | How many times this code has been redeemed so far | +| `parentCodeId` | UUID? | null | FK → InviteCode.id — the code that was redeemed to generate this one | ### New Table: `InviteCodeRedemption` Track each individual redemption event (for auditability and the viral chain). -| Column | Type | Notes | -| -------------- | --------- | ---------------------------------------- | -| `id` | UUID | Primary key | -| `inviteCodeId` | UUID | FK → InviteCode.id (the code redeemed) | +| Column | Type | Notes | +| -------------- | --------- | -------------------------------------------------------- | +| `id` | UUID | Primary key | +| `inviteCodeId` | UUID | FK → InviteCode.id (the code redeemed) | | `childCodeId` | UUID? | FK → InviteCode.id (the code generated for the redeemer) | -| `redeemedAt` | Timestamp | When this redemption occurred | +| `redeemedAt` | Timestamp | When this redemption occurred | ### Migration Strategy @@ -109,11 +108,13 @@ model InviteCodeRedemption { ### 2a. `POST /api/v2/invite-codes/redeem` — Updated **Request body** (unchanged): + ```json { "code": "XKQBWFMR" } ``` **Success response** (`200`) — **updated to include generated code**: + ```json { "success": true, @@ -130,6 +131,7 @@ model InviteCodeRedemption { ``` **Logic changes:** + 1. Look up the code 2. Check `redemptionCount < maxRedemptions` (replaces the `redeemedAt == null` check) 3. Atomically increment `redemptionCount` (use `updateMany` with `where: { code, redemptionCount: { lt: maxRedemptions } }` to prevent races) @@ -139,12 +141,12 @@ model InviteCodeRedemption { **Error responses** (unchanged error codes for backwards compatibility): -| HTTP status | Error code | Meaning | -| ----------- | ----------------------- | ------------------------------------------------------ | -| 404 | `CODE_NOT_FOUND` | No code exists with that value | -| 409 | `CODE_ALREADY_REDEEMED` | Code exists but has reached its max redemptions | -| 422 | `CODE_INVALID_FORMAT` | Malformed code string | -| 401 | — | Invalid or missing JWT | +| HTTP status | Error code | Meaning | +| ----------- | ----------------------- | ----------------------------------------------- | +| 404 | `CODE_NOT_FOUND` | No code exists with that value | +| 409 | `CODE_ALREADY_REDEEMED` | Code exists but has reached its max redemptions | +| 422 | `CODE_INVALID_FORMAT` | Malformed code string | +| 401 | — | Invalid or missing JWT | > Note: We keep `CODE_ALREADY_REDEEMED` as the error code even though a code can now be redeemed multiple times. The meaning is "this code has already been fully redeemed" — semantically close enough, and avoids a breaking change for existing iOS clients. @@ -155,6 +157,7 @@ Check the remaining redemptions for a given invite code. **Authentication**: Requires a valid JWT (same as redeem). **Response** (`200`): + ```json { "success": true, @@ -170,15 +173,16 @@ Check the remaining redemptions for a given invite code. **Error responses:** -| HTTP status | Error code | Meaning | -| ----------- | ---------------- | ------------------------------------- | -| 404 | `CODE_NOT_FOUND` | No code exists with that value | -| 422 | `CODE_INVALID_FORMAT` | Malformed code string | -| 401 | — | Invalid or missing JWT | +| HTTP status | Error code | Meaning | +| ----------- | --------------------- | ------------------------------ | +| 404 | `CODE_NOT_FOUND` | No code exists with that value | +| 422 | `CODE_INVALID_FORMAT` | Malformed code string | +| 401 | — | Invalid or missing JWT | ### 2c. `POST /api/v2/invite-codes/admin/generate` — Updated **Request body** — add optional fields: + ```json { "count": 10, @@ -188,12 +192,12 @@ Check the remaining redemptions for a given invite code. } ``` -| Field | Type | Default | Notes | -| ---------------- | ------- | ------- | ------------------------------------------- | -| `count` | Int | — | Required, 1–500 | -| `batchLabel` | String? | null | Optional batch label | -| `name` | String? | null | Optional name applied to all generated codes| -| `maxRedemptions` | Int? | 1 | Max redemptions for each generated code | +| Field | Type | Default | Notes | +| ---------------- | ------- | ------- | -------------------------------------------- | +| `count` | Int | — | Required, 1–500 | +| `batchLabel` | String? | null | Optional batch label | +| `name` | String? | null | Optional name applied to all generated codes | +| `maxRedemptions` | Int? | 1 | Max redemptions for each generated code | ### 2d. `GET /api/v2/invite-codes/admin/codes` — Updated @@ -215,6 +219,7 @@ Add new fields to the response objects: ``` **Status values** — the list endpoint returns **both** old and new status representations for backwards compatibility: + - `status`: keeps the original values `"pending"` / `"redeemed"` (derived: `redeemed` if `redemptionCount >= maxRedemptions`, `pending` otherwise) - `redeemedAt`: kept — set to the most recent redemption timestamp (or `null`) - New additive fields: `maxRedemptions`, `redemptionCount`, `remainingRedemptions`, `name`, `parentCode` @@ -243,18 +248,18 @@ Update the admin HTML page (`admin-page.ts`) to: ## 5. File-by-File Change List -| File | Change | -|------|--------| -| `prisma/schema.prisma` | Add `name`, `maxRedemptions`, `redemptionCount`, `parentCodeId` to `InviteCode`; keep `redeemedAt`; add `InviteCodeRedemption` model | -| `prisma/migrations/2026XXXX_multi_use_invite_codes/migration.sql` | New migration: alter `InviteCode` (add columns), create `InviteCodeRedemption`, backfill `redemptionCount` from existing `redeemedAt` | -| `src/api/v2/invite-codes/handlers/redeem.ts` | Rewrite redemption logic: check `redemptionCount < maxRedemptions`, atomic increment, generate child code, create redemption row, return child code | -| `src/api/v2/invite-codes/handlers/status.ts` | **New file** — handler for `GET /:code/status` | -| `src/api/v2/invite-codes/handlers/generate.ts` | Accept `name` and `maxRedemptions` in body schema; pass to `createMany` | -| `src/api/v2/invite-codes/handlers/list.ts` | Add new additive fields to response; keep existing `status`/`redeemedAt` fields for compat | -| `src/api/v2/invite-codes/handlers/admin-page.ts` | Update HTML to show new columns, new filter options, new generate form fields | -| `src/api/v2/invite-codes/invite-codes.router.ts` | Add `GET /:code/status` route | -| `src/api/v2/index.ts` | No changes needed (router already mounted) | -| `tests/invite-codes.test.ts` | Update existing tests, add tests for: multi-use redemption, child code generation, status endpoint, exhausted codes | +| File | Change | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prisma/schema.prisma` | Add `name`, `maxRedemptions`, `redemptionCount`, `parentCodeId` to `InviteCode`; keep `redeemedAt`; add `InviteCodeRedemption` model | +| `prisma/migrations/2026XXXX_multi_use_invite_codes/migration.sql` | New migration: alter `InviteCode` (add columns), create `InviteCodeRedemption`, backfill `redemptionCount` from existing `redeemedAt` | +| `src/api/v2/invite-codes/handlers/redeem.ts` | Rewrite redemption logic: check `redemptionCount < maxRedemptions`, atomic increment, generate child code, create redemption row, return child code | +| `src/api/v2/invite-codes/handlers/status.ts` | **New file** — handler for `GET /:code/status` | +| `src/api/v2/invite-codes/handlers/generate.ts` | Accept `name` and `maxRedemptions` in body schema; pass to `createMany` | +| `src/api/v2/invite-codes/handlers/list.ts` | Add new additive fields to response; keep existing `status`/`redeemedAt` fields for compat | +| `src/api/v2/invite-codes/handlers/admin-page.ts` | Update HTML to show new columns, new filter options, new generate form fields | +| `src/api/v2/invite-codes/invite-codes.router.ts` | Add `GET /:code/status` route | +| `src/api/v2/index.ts` | No changes needed (router already mounted) | +| `tests/invite-codes.test.ts` | Update existing tests, add tests for: multi-use redemption, child code generation, status endpoint, exhausted codes | --- @@ -283,10 +288,10 @@ Update the admin HTML page (`admin-page.ts`) to: ## 8. Risks & Mitigations -| Risk | Impact | Mitigation | -|------|--------|------------| -| Race condition on `redemptionCount` increment | High | Use atomic `updateMany` with `where: { redemptionCount: { lt: maxRedemptions } }` — same pattern as current `redeemedAt: null` check | -| Migration on existing data | Medium | Backfill `redemptionCount` from `redeemedAt`; keep `redeemedAt` column; run in transaction | -| ~~Breaking change for iOS~~ | ~~Medium~~ | **Resolved**: keeping `CODE_ALREADY_REDEEMED` error code and `pending`/`redeemed` status values; all new fields are additive | -| Child code generation failure during redemption | Low | Wrap redemption + child creation in a transaction; roll back both on failure | -| Unbounded viral chain depth | Low | Not a concern at 5 uses per child; monitor via `parentCodeId` lineage if needed | +| Risk | Impact | Mitigation | +| ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| Race condition on `redemptionCount` increment | High | Use atomic `updateMany` with `where: { redemptionCount: { lt: maxRedemptions } }` — same pattern as current `redeemedAt: null` check | +| Migration on existing data | Medium | Backfill `redemptionCount` from `redeemedAt`; keep `redeemedAt` column; run in transaction | +| ~~Breaking change for iOS~~ | ~~Medium~~ | **Resolved**: keeping `CODE_ALREADY_REDEEMED` error code and `pending`/`redeemed` status values; all new fields are additive | +| Child code generation failure during redemption | Low | Wrap redemption + child creation in a transaction; roll back both on failure | +| Unbounded viral chain depth | Low | Not a concern at 5 uses per child; monitor via `parentCodeId` lineage if needed | diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 403e57af..350a467b 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { shouldUseDevBehavior, XMTP_ENV } from "@/config"; import { agentApiKeyAuth, authOrAgentApiKeyAuth } from "@/middleware/agentAuth"; import { appCheckOnlyMiddleware, @@ -38,7 +39,7 @@ import { webhookRouter } from "./notifications/webhook.router"; const v2Router = Router(); -if (process.env.XMTP_ENV !== "production") { +if (shouldUseDevBehavior(XMTP_ENV)) { v2Router.use("/dev", devAuthMiddleware, devRouter); } diff --git a/src/api/v2/invite-codes/handlers/list.ts b/src/api/v2/invite-codes/handlers/list.ts index fc4982b9..f4970518 100644 --- a/src/api/v2/invite-codes/handlers/list.ts +++ b/src/api/v2/invite-codes/handlers/list.ts @@ -1,5 +1,5 @@ -import type { Request, Response } from "express"; import { Prisma } from "@prisma/client"; +import type { Request, Response } from "express"; import { z } from "zod"; import { prisma } from "@/utils/prisma"; @@ -44,13 +44,9 @@ export async function listHandler(req: Request, res: Response) { const conditions: Prisma.Sql[] = []; if (status === "pending") { - conditions.push( - Prisma.sql`"redemptionCount" < "maxRedemptions"`, - ); + conditions.push(Prisma.sql`"redemptionCount" < "maxRedemptions"`); } else if (status === "redeemed") { - conditions.push( - Prisma.sql`"redemptionCount" >= "maxRedemptions"`, - ); + conditions.push(Prisma.sql`"redemptionCount" >= "maxRedemptions"`); } if (batchLabel !== undefined) { @@ -118,7 +114,7 @@ export async function listHandler(req: Request, res: Response) { redemptionCount: c.redemptionCount, remainingRedemptions: c.maxRedemptions - c.redemptionCount, parentCode: c.parentCodeId - ? parentCodeMap.get(c.parentCodeId) ?? null + ? (parentCodeMap.get(c.parentCodeId) ?? null) : null, })), total, diff --git a/src/api/v2/notifications/handlers/webhook.ts b/src/api/v2/notifications/handlers/webhook.ts index c48ae639..4b5bedc6 100644 --- a/src/api/v2/notifications/handlers/webhook.ts +++ b/src/api/v2/notifications/handlers/webhook.ts @@ -3,6 +3,7 @@ import type { Request, Response } from "express"; import { createApnsService } from "@/api/v2/notifications/apns-push.service"; import { createFcmService } from "@/api/v2/notifications/fcm-push.service"; import type { V2NotificationPayload } from "@/api/v2/notifications/types"; +import { isXmtpProduction, XMTP_ENV } from "@/config"; import { createNotificationClient, webhookNotificationBodySchema, @@ -263,10 +264,7 @@ async function handleV2Notification(args: { }); // Auto-disable in XMTP production only to preserve test devices in dev/staging for debugging - if ( - process.env.XMTP_ENV === "production" && - u.pushFailures >= MAX_PUSH_FAILURES - ) { + if (isXmtpProduction(XMTP_ENV) && u.pushFailures >= MAX_PUSH_FAILURES) { await tx.deviceRegistration.updateMany({ where: { deviceId: client.deviceId, diff --git a/src/config.ts b/src/config.ts index 3c1e4c56..beaab92c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,4 +35,34 @@ export const AGENT_POOL_API_KEY = process.env.AGENT_POOL_API_KEY || ""; // Agent asset upload auth (optional — endpoint returns 503 if not configured) export const AGENT_ASSETS_API_KEY = process.env.AGENT_ASSETS_API_KEY || ""; -export const XMTP_ENV = process.env.XMTP_ENV || "dev"; +export const VALID_XMTP_ENVS = [ + "production", + "testnet", + "dev", + "local", +] as const; +export type XmtpEnv = (typeof VALID_XMTP_ENVS)[number]; + +function isValidXmtpEnv(value: string): value is XmtpEnv { + return (VALID_XMTP_ENVS as readonly string[]).includes(value); +} + +export function parseXmtpEnv(value = process.env.XMTP_ENV || "dev"): XmtpEnv { + if (!isValidXmtpEnv(value)) { + throw new Error( + `Invalid XMTP_ENV: ${value}. Must be one of: ${VALID_XMTP_ENVS.join(", ")}`, + ); + } + + return value; +} + +export const XMTP_ENV = parseXmtpEnv(); + +export function isXmtpProduction(xmtpEnv: XmtpEnv = XMTP_ENV): boolean { + return xmtpEnv === "production"; +} + +export function shouldUseDevBehavior(xmtpEnv: XmtpEnv = XMTP_ENV): boolean { + return !isXmtpProduction(xmtpEnv); +} diff --git a/tests/config-environment.test.ts b/tests/config-environment.test.ts new file mode 100644 index 00000000..dcd1f538 --- /dev/null +++ b/tests/config-environment.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { isXmtpProduction, parseXmtpEnv, shouldUseDevBehavior } from "@/config"; + +describe("XMTP environment helpers", () => { + test("treats only production as production", () => { + expect(isXmtpProduction("production")).toBe(true); + expect(isXmtpProduction("dev")).toBe(false); + expect(isXmtpProduction("testnet")).toBe(false); + expect(isXmtpProduction("local")).toBe(false); + }); + + test("treats testnet as dev-like behavior", () => { + expect(shouldUseDevBehavior("dev")).toBe(true); + expect(shouldUseDevBehavior("testnet")).toBe(true); + expect(shouldUseDevBehavior("local")).toBe(true); + expect(shouldUseDevBehavior("production")).toBe(false); + }); + + test("rejects invalid environment values", () => { + expect(() => parseXmtpEnv("staging")).toThrow( + "Invalid XMTP_ENV: staging. Must be one of: production, testnet, dev, local", + ); + }); +}); diff --git a/tests/invite-codes.test.ts b/tests/invite-codes.test.ts index a657a657..37247429 100644 --- a/tests/invite-codes.test.ts +++ b/tests/invite-codes.test.ts @@ -199,7 +199,6 @@ describe("Invite Codes API Tests", () => { }); expect(redemptions).toHaveLength(1); const firstRedemption = redemptions[0]; - if (!firstRedemption) throw new Error("Expected a redemption record"); expect(firstRedemption.childCodeId).toBe(childCode.id); }); @@ -408,9 +407,7 @@ describe("Invite Codes API Tests", () => { }); test("should return 422 for invalid code format", async () => { - const response = await fetch( - `${baseURL}/api/v2/invite-codes/bad/status`, - ); + const response = await fetch(`${baseURL}/api/v2/invite-codes/bad/status`); expect(response.status).toBe(422); const data = (await response.json()) as { error: string }; diff --git a/tests/renew-batch.test.ts b/tests/renew-batch.test.ts index 39e16659..a5f161d9 100644 --- a/tests/renew-batch.test.ts +++ b/tests/renew-batch.test.ts @@ -32,6 +32,36 @@ void mock.module("@aws-sdk/client-s3", () => ({ return mockS3Send(command); } }, + DeleteObjectCommand: class DeleteObjectCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, + HeadBucketCommand: class HeadBucketCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, + HeadObjectCommand: class HeadObjectCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, + ListObjectsV2Command: class ListObjectsV2Command { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, + PutObjectCommand: class PutObjectCommand { + input: unknown; + constructor(input: unknown) { + this.input = input; + } + }, CopyObjectCommand: class { // eslint-disable-next-line @typescript-eslint/no-explicit-any input: any; @@ -67,11 +97,11 @@ app.post("/api/v2/assets/renew-batch", renewBatchHandler); describe("POST /api/v2/assets/renew-batch", () => { let server: Server; - const baseURL = "http://localhost:4002"; + const baseURL = "http://localhost:4005"; beforeAll(async () => { await new Promise((resolve) => { - server = app.listen(4002, () => { + server = app.listen(4005, () => { resolve(); }); });