# Multi-Tenancy & Customer Onboarding

This document covers what needs to happen to the database and codebase before and between customer onboardings.

---

## Dev vs production config strategy

Tenant config files serve one purpose: **seeding**. At runtime, all config (AI settings, notification flags, category abbreviations, etc.) is read from the `tenant_configs` DB table. The YAML is only read when running `pnpm db:seed`.

Because Vercel builds from the git repo, config files must be committed. The information in them — company names, email addresses, project names — is not sensitive enough to warrant secret management.

**One YAML file per tenant, named by purpose:**

| File | Purpose |
|---|---|
| `tenant.config.yaml` | Dev default — placeholder emails, dev projects, dev KB fixtures |
| `tenant.config.prod.yaml` | Pablo's client tenant |
| `tenant.config.prod.operators.yaml` | Operators tenant (James + Ramon) |

To seed a tenant, run:

```bash
TENANT_CONFIG_PATH=./tenant.config.prod.yaml          DATABASE_URL=<prod> pnpm db:seed
TENANT_CONFIG_PATH=./tenant.config.prod.operators.yaml DATABASE_URL=<prod> pnpm db:seed
```

Each run is idempotent — safe to repeat. The seed script matches on `tenant.slug` and upserts rather than inserting duplicate rows.

The `TENANT_CONFIG_PATH` environment variable is **only needed at seed time**. It does not need to be set in Vercel (all runtime config comes from the DB). If it is set, it is harmlessly ignored by the running application.

The local dev container uses `tenant.config.yaml` by default (no env var needed).

**Who belongs where:**

- `tenant.config.prod.yaml` — Pablo's email in `initial_expert_invites`; line managers added by Pablo via the admin UI after first login; `initial_projects: []`
- `tenant.config.prod.operators.yaml` — James and Ramon in `initial_dual_access_invites` (admin role); no experts, no line managers, no projects
- `tenant.config.yaml` (dev) — keeps example emails, dev projects, and dev KB fixtures.

Once the invite guard is in place, not being in the production config means you cannot log into production even accidentally — which is the right behavior for a developer.

---

## Before Customer 1: code changes first, then wipe

The development database contains placeholder users, test tickets, and dev knowledge entries. None of that should be present when a real customer starts. **But before wiping, two small code changes must ship.**

### Pre-wipe checklist

These must be implemented, tested, and deployed before the database is wiped:

**1. Invite guard in `createUser` (`src/auth.ts`)**

Currently any email that authenticates via magic link or Google is auto-provisioned as a `line_manager` in the first tenant. Replace this with an explicit check: if the email is not in the tenant's invite lists, reject sign-in entirely.

Implementation:
- In the `createUser` adapter, query `tenant_configs` for the single tenant
- Check the email against `initialExpertInvites`, `initialDualAccessInvites`, and the line manager invite list (currently in the YAML only — see below)
- If not found, throw an error so Auth.js aborts the session creation
- Role assignment: if in `initialExpertInvites` → `expert`; if in `initialDualAccessInvites` → `admin`; if in `initial_line_manager_invites` → `line_manager`

Note: `initial_line_manager_invites` currently lives only in the YAML and is not stored in the DB (`tenant_configs` only stores expert and dual-access invite lists). Either add it to `tenant_configs` as part of this change, or read both sources in `createUser`. Adding it to the DB is cleaner.

**2. `SEED_DEMO_DATA` flag in `scripts/seed.ts`**

The seeder unconditionally calls `seedTickets` and `seedKnowledgeEntries`, which insert dev fixtures inappropriate for production. Add a `SEED_DEMO_DATA` environment variable: when absent or `false`, skip those two functions. The tenant row, config, users, and projects are always seeded.

When running the production seed, omit `SEED_DEMO_DATA` and the DB will have real users and projects but no dev tickets or KB entries — which is correct. Pablo's client starts with a clean knowledge base.

### Production seed steps (after code is deployed)

1. Create `tenant.config.prod.yaml` with Pablo's company name, slug, his email as expert, the line manager's email, and `initial_projects: []` (Pablo adds real projects via the admin UI after first login)
2. Wipe the production database (drop and recreate all data rows, keep schema)
3. Run the seeder against the production Neon database:
   ```bash
   TENANT_CONFIG_PATH=./tenant.config.prod.yaml DATABASE_URL=<prod-neon-url> pnpm db:seed
   ```
4. Pablo logs in via magic link — his user row already exists from the seeder, role is `expert`
5. Pablo tells the line manager the app URL out of band; the line manager enters their email, receives the standard magic link email, and lands in the app with `line_manager` role

There is no separate invitation email. The magic link is the invite mechanism — the invite guard simply ensures that only pre-registered emails can trigger one. Auth.js's `createUser` adapter is only triggered for users who have never logged in before — since the seeder pre-creates the rows, it finds them and skips creation.

### Post-wipe verification

- [ ] Pablo can log in and sees the expert inbox (empty)
- [ ] Line manager can log in and sees the submit form
- [ ] An unknown email is rejected at login
- [ ] No dev tickets are present
- [ ] Knowledge base is empty

---

## Before Customer 2, 3, …: no wipe needed

The architecture is already built for multiple tenants. Every table has a `tenant_id` column and every query is scoped to it. Adding a new customer is:

1. Insert a new row in `tenants` and `tenant_configs`
2. Update `tenant.config.prod.yaml` (or create a second production config) and run the seeder for that tenant
3. Create users and memberships for the new customer's team
4. Their data is completely isolated from all other tenants by `tenant_id`

### One prerequisite before Customer 2: RBAC migration

The current implementation stores `role` directly on the `users` table (`users.role`) and binds every user to exactly one tenant via `users.tenant_id`. This works correctly for a single customer, but breaks when:

- The same email address needs to belong to two tenants (e.g. Pablo acting as expert for a second client)
- The same person needs a different role in each tenant

**Before onboarding Customer 2, the RBAC must be migrated from `users.role` / `users.tenant_id` to `tenant_memberships`.** The full implementation spec is in the section below.

---

## RBAC migration spec (before Customer 2)

### Overview

Make user identities global (no `tenant_id` on `users`), and express role + tenant access through a join table `tenant_memberships`. The `tenant_memberships` table is already described in `docs/technical_spec.md §3.4` but has not been built yet.

Tenant routing at login uses **invite-only lookup**: because every user's email is pre-registered to a specific tenant (or multiple tenants) via the invite lists, the system can resolve `email → tenant(s)` at login time without subdomain routing or any other infrastructure change.

### Database changes

**Migration: additive, non-breaking**

```sql
-- New join table
CREATE TABLE tenant_memberships (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id),
  user_id     UUID NOT NULL REFERENCES users(id),
  role        TEXT NOT NULL CHECK (role IN ('line_manager', 'expert', 'admin')),
  is_active   BOOLEAN NOT NULL DEFAULT true,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, user_id)
);

-- Backfill from existing users table
INSERT INTO tenant_memberships (tenant_id, user_id, role)
SELECT tenant_id, id, role FROM users;

-- Remove tenant-specific columns from users
ALTER TABLE users DROP COLUMN tenant_id;
ALTER TABLE users DROP COLUMN role;
```

**Drizzle schema changes:**

- Remove `tenantId` and `role` from `src/db/schema/users.ts`
- Add `src/db/schema/memberships.ts` with the `tenantMemberships` table

### Auth layer changes (`src/auth.ts`)

**`createUser` adapter:**

Replace the current `.limit(1)` tenant grab with an invite-list lookup:

1. Query all `tenant_configs` rows, checking `initialExpertInvites`, `initialDualAccessInvites`, and `initialLineManagerInvites` for the new email
2. If found in exactly one tenant: create the `users` row (no `tenantId`), then insert a `tenant_memberships` row with the matched role
3. If found in multiple tenants: create the `users` row, insert a membership for each matched tenant
4. If not found: throw — login is rejected

**Session callback:**

Currently reads `tenantId` and `role` directly from `users`. After migration, the session must resolve the user's active tenant context. For a user who belongs to exactly one tenant (almost everyone), this is straightforward: query `tenant_memberships` for the user, take the single result.

For Pablo (or any consultant in multiple tenants in the future): the session needs a concept of "active tenant". Options:
- Default to the first/only tenant on login; expose a tenant-switcher UI when there is more than one
- Or store `active_tenant_id` in the Auth.js `sessions` table

The tenant-switcher is a small UI addition — a dropdown in the nav when `memberships.length > 1`. For the Customer 2 launch, Pablo is the only person who will have multiple memberships, and it can be a simple page-level switch rather than a fancy component.

**Session shape after migration:**

```ts
session.user.id           // unchanged
session.user.tenantId     // resolved from active membership
session.user.role         // resolved from active membership
```

The session shape exposed to the rest of the app does not change. All existing API routes that read `session.user.tenantId` and `session.user.role` continue to work without modification.

### Seeder changes (`scripts/seed.ts`)

After the migration, the seeder inserts into `tenant_memberships` instead of setting `tenantId` and `role` on `users`:

```ts
// Create global user (no tenantId)
await db.insert(users).values({ email, displayName: null })
// Create membership
await db.insert(tenantMemberships).values({ tenantId, userId: user.id, role })
```

The seeder is idempotent — if the user row already exists (looked up by email), skip insert and just ensure the membership row exists.

### Invite guard update

After the migration, the invite guard in `createUser` writes to `tenant_memberships` rather than setting `users.role` and `users.tenantId`. The logic is otherwise identical.

### API route changes

No changes required to existing route logic. All routes read `session.user.tenantId` and `session.user.role`, which are still populated correctly by the session callback — just sourced from `tenant_memberships` instead of `users` directly.

### Testing requirements

Per project rules, the following tests must ship with this change:

- Update `tests/tenant-config-loader.test.ts` if `initialLineManagerInvites` is added to the config schema
- Integration test for the updated `createUser` path: new email in invite list → user + membership created; unknown email → rejected; email in two tenant invite lists → two memberships created
- Integration test for the session callback: user with one membership → correct `tenantId` and `role`; user with two memberships → active tenant resolves correctly
- Existing route integration tests should pass without changes (session shape is preserved)

### Migration order of operations

1. Write and run the Drizzle migration (new table + backfill + drop columns)
2. Deploy updated `src/db/schema/` and `src/auth.ts`
3. Verify Customer 1 can still log in and sessions resolve correctly
4. Insert new tenant row + config + run seeder for Customer 2
5. Verify Customer 2 users can log in and see only their tenant's data
6. Verify Pablo (if now in both tenants) sees the tenant-switcher and can access each correctly

---

## Knowledge base: fully per-client, by design

`knowledge_entries` has a `tenant_id` column. The AI retrieval hook (Hook 5 in the spec) filters by tenant before doing any vector search. Customer A's knowledge base is never visible to Customer B.

This is intentional for two reasons:

**Confidentiality.** Each customer's KB accumulates their proprietary procedures, equipment-specific failure modes, internal deviation history, and remediation approaches. That is none of another customer's business.

**Relevance.** The knowledge that builds up from resolving tickets for one client — their specific equipment, their regulatory context, their internal processes — is mostly not useful to a different client operating in a different facility or industry.

**Project names** are also per-client. They seed from `initial_projects` in `tenant.config.prod.yaml` into the `projects` table with `tenant_id`, so each tenant has their own project list visible only to their users.

### Seed KB for new customers

New customers start with an empty knowledge base. The AI can still assist, but the quality of suggested responses improves as tickets are resolved and KB entries are built up (via Hooks 6 and 7 in the spec).

---

## The generic knowledge library

Customer Zero is a consultant, which makes him uniquely well-placed to distinguish between knowledge that is specific to one client's equipment and processes versus knowledge that would be useful to any client. This is the foundation for a **generic knowledge library** — a curated pool of reusable KB entries that new customers are seeded with at onboarding.

### How it works

Add an `is_generic` boolean flag to `knowledge_entries`. Customer Zero's expert reviews entries periodically and marks them as generic. When a new customer is onboarded, the generic entries are **copied** to their tenant as new rows with a new `tenant_id`. From that point, the new tenant owns their copy — they can edit, extend, or delete entries freely without affecting the library or any other tenant. Isolation stays fully intact.

This means generic entries don't drift across tenants. If the library is improved later, existing customers keep their (potentially customized) copy and only new onboardings get the updated version. That is the right behavior.

### Two tiers of generic knowledge

Not all generic knowledge applies equally to all customers. There is a useful distinction between:

- **Cross-industry generic** — fundamental quality management principles, ISO vocabulary, audit preparation, GMP concepts that apply regardless of what industry the customer is in
- **Industry-specific generic** — knowledge that applies to all customers in a given industry but not outside it (e.g. 21 CFR Part 820, ISO 13485, CAPA procedure templates for medical device manufacturers)

Industry should be captured as a tag on each generic entry (e.g. `industry:medical_device`, `industry:food_manufacturing`, `industry:general_gmp`). The existing `category_tags` array on `knowledge_entries` can carry this, or it can be a dedicated field. Either way, when onboarding a new customer the seeding step copies only the entries whose industry tags are relevant to that customer — a food manufacturer does not get seeded with medical device regulatory procedures.

### Why this matters as we grow

The initial target market is medical device manufacturing, but we do not know which industries will find and want to use the product. Tagging generic entries by industry from the start means:

- Onboarding a customer in a new industry is a matter of having the right generic entries ready for them, not rebuilding the process
- Customer Zero can write new KB entries with a "could this help anyone?" mindset rather than defaulting to client-specific framing
- Over time the generic library becomes a competitive asset — every new customer benefits from the accumulated knowledge of every expert who has contributed to it

### Expert workflow

The UI change needed to support this is small: a toggle or badge on each KB entry in the knowledge admin view indicating whether it is generic or client-specific. Ideally Customer Zero can also write new entries directly into the generic library rather than creating them inside his own tenant and flagging them afterwards. Writing to the library intentionally, rather than as an afterthought, keeps the quality higher.

A simple admin view listing all generic entries — filterable by industry tag — serves as the library browser for platform-level review before entries are promoted or used in a new onboarding.

---

## Summary table

| Scenario | DB wipe? | Code change? | Action required |
|---|---|---|---|
| Before Customer 1 goes live | Yes — wipe dev data | Yes — invite guard + SEED_DEMO_DATA flag | Create prod config, deploy changes, run prod seeder |
| Customer 2 onboards | No | Yes — RBAC migration to `tenant_memberships` | New tenant row + prod config entry + users |
| Customer 3+ onboards | No | No (once RBAC is done) | New tenant row + config + users |
| Customer KB at start | — | — | Empty; grows from resolved tickets |
| Customer KB isolation | — | — | Enforced by `tenant_id` on all queries and vector retrieval |
| Generic library grows | — | — | Customer Zero flags entries as generic; industry-tagged for relevance filtering |
