Skip to content
This repository was archived by the owner on Aug 12, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 54 additions & 49 deletions docs/plans/invite-code-multi-use.md
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low plans/invite-code-multi-use.md:4

The > **Branch**: and > **Parent PR**: metadata lines were collapsed onto a single line, so the literal > character appears mid-line and > **Parent PR**: is no longer rendered as a separate blockquote line. This malformats the document header.

Suggested change
> **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)
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file docs/plans/invite-code-multi-use.md around line 4:

The `> **Branch**:` and `> **Parent PR**:` metadata lines were collapsed onto a single line, so the literal `>` character appears mid-line and `> **Parent PR**:` is no longer rendered as a separate blockquote line. This malformats the document header.

> **Created**: 2026-04-01

## Overview
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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.

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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

Expand All @@ -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`
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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 |
3 changes: 2 additions & 1 deletion src/api/v2/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Router } from "express";
import { shouldUseDevBehavior, XMTP_ENV } from "@/config";
import { agentApiKeyAuth, authOrAgentApiKeyAuth } from "@/middleware/agentAuth";
import {
appCheckOnlyMiddleware,
Expand Down Expand Up @@ -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);
}

Expand Down
12 changes: 4 additions & 8 deletions src/api/v2/invite-codes/handlers/list.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions src/api/v2/notifications/handlers/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
24 changes: 24 additions & 0 deletions tests/config-environment.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
5 changes: 1 addition & 4 deletions tests/invite-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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 };
Expand Down
Loading
Loading