# Multi-Tenant Switcher Spec

**Prerequisite:** RBAC migration to `tenant_memberships` — completed on `feat/rbac-tenant-memberships`  
**Trigger:** When a second customer is ready to onboard, or when the operators (James, Ramon) want their own isolated tenant  
**Risk level:** Low — data model is already correct; this is plumbing and UI

---

## What this enables

After this work, James and Ramon can have their own tenant (their consulting practice, or a sandbox) that is completely isolated from Pablo's clients. A single email address can belong to multiple tenants with different roles in each. Logging in still works the same way — the app picks the right tenant automatically, and a switcher appears in the nav if you have access to more than one.

From the outside, the app behaves identically. From the inside, every query is already scoped by `tenant_id`, and `tenant_memberships` already supports the many-to-many relationship. The remaining work is:

1. Move three config fields from YAML into the `tenantConfigs` DB table (eliminates the runtime YAML dependency)
2. Update the seed strategy so each tenant has its own config file and seed run
3. Update the session callback to handle a user who belongs to multiple tenants
4. Add a `/api/tenant/switch` endpoint and a tenant switcher in the nav

---

## What is already in place

- `tenant_memberships` table with `(tenantId, userId)` unique index and CASCADE on `userId`
- Session shape `session.user.tenantId` and `session.user.role` unchanged — all route guards and queries work as-is
- All queries are already scoped by `tenantId` from the session
- `TENANT_CONFIG_PATH` env var for pointing to a specific config file at seed time
- Idempotent seed script (`onConflictDoUpdate` for both `users` and `tenantMemberships`)

---

## Gap 1: Move runtime config fields from YAML to DB

### Problem

Three config fields are currently read from the YAML file **at request time** (not just at seed time):

| Field | Used in | Purpose |
|---|---|---|
| `intake.category_abbreviations` | `POST /api/tickets` | Generates ticket reference numbers like `AQ-VAL-001` |
| `notifications.include_response_in_email` | `POST /api/tickets/[id]/reply`, `POST /api/tickets/[id]/respond` | Controls whether response text is included in the notification email |
| `notifications.expert_digest_enabled` | `inngest/expert-digest.ts` | Controls whether the digest job sends email for a given tenant |

With a single tenant, pointing `TENANT_CONFIG_PATH` at one file works. With two tenants, a request for Tenant A cannot load Tenant B's config file — the app needs to resolve config **by tenant slug at runtime**, and all three fields need to come from the DB.

All other config fields (AI settings, prompt text, etc.) are already stored in the `tenantConfigs` DB table.

### Changes

**`tenantConfigs` DB table — add three columns:**

```ts
categoryAbbreviations: jsonb('category_abbreviations')
  .notNull()
  .default(sql`'{"Validation":"VAL","Equipment":"EQP","Compliance":"CMP","Deviation":"DEV","Other":"OTH"}'::jsonb`),
includeResponseInEmail: boolean('include_response_in_email').notNull().default(true),
expertDigestEnabled: boolean('expert_digest_enabled').notNull().default(true),
```

**Drizzle migration:** one additive migration adding the three columns with their defaults. No data loss; existing rows get sensible defaults.

**`src/app/api/tickets/route.ts`:** replace `loadTenantConfig(CONFIG_PATH).intake.category_abbreviations` with a DB lookup by `session.user.tenantId`.

**`src/app/api/tickets/[ticketId]/reply/route.ts`** and **`respond/route.ts`:** same — replace YAML lookup with `tenantConfigs` row for the current tenant.

**`src/inngest/expert-digest.ts`:** the digest already queries `tenants` table in a loop; add `tenantConfigs.expertDigestEnabled` to that query's JOIN rather than reading from a single config file.

**`scripts/seed.ts`:** write the three fields into `tenantConfigs` during seed, sourcing from the YAML. No change to the YAML format — this is purely a DB write that wasn't happening before.

**After this change, the YAML file is used exclusively at seed time.** The `TENANT_CONFIG_PATH` env var (and the Vercel environment variable pointing at `tenant.config.prod.yaml`) can be removed from Vercel once the DB is seeded. It only needs to be present when running `pnpm db:seed`.

### Testing

- Add three cases to `tests/tenant-config-loader.test.ts` — or more accurately, update whichever test covers the ticket POST route to verify `categoryAbbreviations` comes from DB not YAML
- `tests/expert-digest.integration.test.ts` already stubs `configPath`; after this change, that stub can be removed and the test reads from `tenantConfigs` in the DB (the test seeds the row)

---

## Gap 2: Seed strategy for multiple tenants

### Problem

The seed script currently seeds **one tenant per run**, identified by `TENANT_CONFIG_PATH`. That is fine. The design question is how to manage config files for N tenants and make the seed idempotent (safe to re-run without creating duplicate data).

### Recommended approach: one YAML file per tenant, run seed once per tenant

Each tenant gets a dedicated config file. File naming convention:

```
tenant.config.yaml               ← dev default (unchanged)
tenant.config.prod.customer1.yaml ← James and Ramon's tenant
tenant.config.prod.pablo.yaml     ← Pablo's client(s) tenant
```

To seed a new tenant:

```bash
TENANT_CONFIG_PATH=./tenant.config.prod.customer1.yaml DATABASE_URL=<prod-neon-url> pnpm db:seed
TENANT_CONFIG_PATH=./tenant.config.prod.pablo.yaml    DATABASE_URL=<prod-neon-url> pnpm db:seed
```

Both runs are safe to repeat — upsert logic means re-running does not create duplicate users or memberships.

### What changes in the seed script

The seed script does **not** need to change for basic multi-tenant seeding. `TENANT_CONFIG_PATH` already controls which file to read. The upsert-based approach already handles idempotency.

One addition is worth making: **a check that prevents accidentally seeding the wrong tenant's data into an existing tenant by verifying that a re-run matches the slug of an existing tenant row** rather than creating a new one silently. If the slug matches an existing `tenants` row, update it. If it does not match, insert a new one. This is already the current behavior — just documenting it explicitly.

### Committed config files

All production config YAMLs are committed to the repo (as documented in `multi_tenancy_onboarding.md`). They contain company names and email addresses, which are not sensitive enough to warrant secret management. The `tenant.config.prod.*.yaml` pattern makes it clear which files are production configs. The `SEED_DEMO_DATA` env flag (already implemented) prevents test data from being seeded in production.

---

## Gap 3: Session tenant resolution for multi-tenant users

### Problem

The session callback currently does:

```ts
const [membership] = await db
  .select(...)
  .from(tenantMemberships)
  .where(and(eq(tenantMemberships.userId, user.id), eq(tenantMemberships.isActive, true)))
  .limit(1)

session.user.tenantId = membership?.tenantId ?? ''
session.user.role     = membership?.role ?? 'line_manager'
```

For a user in exactly one tenant this is correct. For a user in two tenants (e.g. James, who is admin of his own tenant and also admin of Pablo's tenant), `.limit(1)` returns whichever the DB happens to return first. That is not deterministic and gives no way to switch.

### Solution: preferred tenant cookie

Store the user's active tenant selection in a **signed httpOnly cookie** (`preferred-tenant-id`). The session callback reads the cookie before falling back to the first active membership.

**Why a cookie over a DB column:**
- No additional migration
- The cookie is scoped to the browser — switching tenant in one tab does not affect another device
- The callback already runs in the request context and can read cookies

**Session callback changes (`src/auth.ts`):**

```ts
// 1. Get all active memberships for this user
const memberships = await db
  .select({ tenantId: tenantMemberships.tenantId, role: tenantMemberships.role })
  .from(tenantMemberships)
  .where(and(eq(tenantMemberships.userId, user.id), eq(tenantMemberships.isActive, true)))

if (memberships.length === 0) {
  session.user.tenantId = ''
  session.user.role = 'line_manager'
  return session
}

// 2. If the preferred-tenant-id cookie is set AND the user has a membership there, use it
const preferred = cookies().get('preferred-tenant-id')?.value
const preferredMembership = preferred
  ? memberships.find((m) => m.tenantId === preferred)
  : undefined

// 3. Fall back to first active membership
const active = preferredMembership ?? memberships[0]
session.user.tenantId = active.tenantId
session.user.role     = active.role

// 4. Expose whether this user has multiple tenants (for the nav switcher)
session.user.hasMultipleTenants = memberships.length > 1
```

**`session.user` type extension (`src/auth.ts` or `types/next-auth.d.ts`):**

```ts
hasMultipleTenants?: boolean
```

### Switch endpoint (`src/app/api/tenant/switch/route.ts`)

```
POST /api/tenant/switch
Body: { tenantId: string }
Auth: any authenticated user
```

- Validates the requesting user has an active membership in the requested `tenantId`
- Sets `preferred-tenant-id` cookie (httpOnly, secure, sameSite=lax, maxAge=90 days)
- Returns `200 OK`
- Client calls `router.refresh()` after the response, which re-runs the session callback with the new cookie and re-renders server components

No session reissue required — `router.refresh()` in Next.js App Router triggers a server re-render without a full page reload, and the session callback re-runs on the next request.

---

## Gap 4: Tenant switcher UI

### Nav changes (`src/app/Nav.tsx`)

When `session.user.hasMultipleTenants` is `true`, show the current tenant name with a "Switch tenant" affordance. For MVP this can be a simple dropdown; for the first release it can be a link to a dedicated `/switch-tenant` page.

**Option A (simpler): dedicated page**

Add a `/switch-tenant` page that lists the user's active memberships (fetched from a new `GET /api/tenant/memberships` endpoint) and lets them click to switch. After switching, redirect to the inbox.

**Option B (cleaner UX): nav dropdown**

The nav already has a user menu. When `hasMultipleTenants` is true, show a "Switch tenant" submenu. A click calls `POST /api/tenant/switch` and then `router.refresh()`.

**Recommendation:** Option A for the first pass. It is one page component and one read endpoint. Option B can follow once multi-tenant is stable. The important thing is that single-tenant users (everyone except James and Ramon at launch) see no change in the nav.

### Nav display: current tenant name

For context, show the current tenant name somewhere in the nav. This requires either:
- Storing tenant name in the session (add `session.user.tenantName`)
- Or fetching it from a server component on the layout

The simpler path: add `tenantName` to the session callback output (it is already in the `tenants` table, one extra JOIN in the session callback).

### New endpoint: `GET /api/tenant/memberships`

Returns the list of tenants the current user is active in (id, name, slug, current role). Used by the `/switch-tenant` page to render the list.

```ts
// Response shape
[
  { tenantId: string, tenantName: string, role: string, isCurrent: boolean },
  ...
]
```

---

## Files touched — full punch list

### Migration (Gap 1)

- [ ] `src/db/schema/tenantConfigs.ts` — add `categoryAbbreviations`, `includeResponseInEmail`, `expertDigestEnabled` columns
- [ ] Generate Drizzle migration (`pnpm drizzle-kit generate`)
- [ ] Apply migration (`pnpm drizzle-kit migrate`)
- [ ] `scripts/seed.ts` — write three new fields into `tenantConfigs` during seed
- [ ] `src/app/api/tickets/route.ts` — replace YAML config lookup with DB query
- [ ] `src/app/api/tickets/[ticketId]/reply/route.ts` — same
- [ ] `src/app/api/tickets/[ticketId]/respond/route.ts` — same
- [ ] `src/inngest/expert-digest.ts` — remove `configPath` param, read `expertDigestEnabled` from DB JOIN
- [ ] `src/lib/tenant-config-loader.ts` — can eventually be deleted (only needed for seed); or scoped to seed-only imports
- [ ] Update `tests/expert-digest.integration.test.ts` — remove `configPath` stub; ensure `expertDigestEnabled` is set via seeded `tenantConfigs` row

### Seed strategy (Gap 2)

- [ ] Create `tenant.config.prod.customer1.yaml` (James + Ramon's tenant)
- [ ] Rename/update `tenant.config.prod.yaml` → `tenant.config.prod.pablo.yaml` (or per customer slug)
- [ ] Update `docs/deployment.md` and `docs/multi_tenancy_onboarding.md` to document the new file naming convention and per-tenant seed command

### Session resolution (Gap 3)

- [ ] `src/auth.ts` — update session callback to read all active memberships, check `preferred-tenant-id` cookie, set `hasMultipleTenants`
- [ ] `src/types/next-auth.d.ts` (or equivalent) — add `hasMultipleTenants?: boolean` and `tenantName?: string` to session user type
- [ ] `src/app/api/tenant/switch/route.ts` — new POST endpoint; validates membership, sets cookie
- [ ] Update `tests/rbac-membership.integration.test.ts` — add cases for multi-membership session resolution: user in two tenants + no cookie → first membership; user in two tenants + valid cookie → cookie tenant; user in two tenants + cookie for tenant they are not in → falls back to first

### Switcher UI (Gap 4)

- [ ] `src/app/api/tenant/memberships/route.ts` — new GET endpoint; returns user's active memberships with tenant names
- [ ] `src/app/(app)/switch-tenant/page.tsx` — new page; lists tenants, calls switch endpoint on click, redirects to inbox
- [ ] `src/app/Nav.tsx` — show current tenant name; show "Switch tenant" link when `hasMultipleTenants` is true

---

## Migration order of operations (production)

1. Deploy Gap 1 (DB migration + code) — verify Customer 1 (Pablo's tenant) still works correctly; expert digest still fires
2. Create `tenant.config.prod.customer1.yaml` with James + Ramon's emails; seed their tenant against prod DB
3. Deploy Gap 3 (session callback) — verify Pablo still lands in his tenant; James lands in his tenant by default
4. Deploy Gap 4 (switcher UI) — James can now switch between his tenant and Pablo's tenant via the nav
5. Update Vercel to remove `TENANT_CONFIG_PATH` env var (no longer needed at runtime after Gap 1)

---

## What does NOT change

- The `session.user.tenantId` and `session.user.role` shape — all route guards, queries, and server components continue to work without modification
- The invite-only login guard — each tenant's invite list is in its own YAML, seeded into `tenantMemberships`; the guard already works per-membership
- Data isolation — every query is already scoped by `tenantId`; nothing changes here
- Single-tenant users — the session callback path for a user in exactly one tenant is identical; they never see the switcher
