# PRP-003 — Tenancy & Root Admin console

- **Status:** Draft
- **Plan:** Lite
- **Depends on:** PRP-001, PRP-002 (event bus)
- **Estimated effort:** ~1 day
- **Pro KB ref:** §1.2, §6.3 (super-admin)

> **Audience:** junior dev. The root console already lists companies, shows stats, and toggles
> status/plan. This PRP adds **manual company creation by root** (company + its single manager,
> seeded zones, optional credentials email) and a **cross-tenant isolation regression test**.

---

## 1. Goal / Why
The Shiprex root operator runs sales-assisted onboarding: create a company on a phone call, hand the
manager their login. We also must *prove* tenants can't see each other's data.

## 2. Scope
**In:** `POST /admin/companies` (manual create) + root UI form; emit `company.created`
(for credentials email in PRP-009); confirm plan=pro lifts the daily cap; isolation test.
**Out (Pro):** branches/regions, sub-accounts, white-label.

## 3. Prerequisites
- PRP-001 + PRP-002 merged. `server/src/lib/events.js` exists.
- Know how to get a **root token**: `POST /api/auth/login` with `admin@shiprex.io` / `ChangeMe123!`.

## 4. Step-by-step implementation

### Step 1 — Extract a reusable "create company + manager + zones" helper
Today the registration logic lives inside `auth.service.registerCompany`. To avoid duplicating the
transaction, refactor the shared part into a small service the admin route can reuse.

1. Create **`server/src/modules/companies/companies.service.js`**:
   ```js
   const db = require('../../db/knex');
   const { hashPassword } = require('../../utils/password');
   const { ROLES, PLANS, DEFAULT_CURRENCY } = require('../../config/constants');
   const { getZonePreset } = require('../zones/zone-presets');
   const { bus, EVENTS } = require('../../lib/events');

   function slugify(name) {
     return String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 180);
   }
   function httpError(message, status) { const e = new Error(message); e.status = status; return e; }

   async function uniqueSlug(trx, base) {
     let slug = base || 'company';
     let n = 1;
     // eslint-disable-next-line no-await-in-loop
     while (await trx('companies').where({ slug }).first()) slug = `${base}-${n++}`;
     return slug;
   }

   /**
    * Creates a company + its single manager + seeded zones in one transaction.
    * Returns { company, user }. Does NOT emit events (caller decides which to emit).
    */
   async function createCompanyWithManager(input, trx) {
     const email = input.email.trim().toLowerCase();
     const country = String(input.country || '').toUpperCase().slice(0, 2);

     const existing = await trx('users').where({ email }).first();
     if (existing) throw httpError('Email is already registered', 409);

     const slug = await uniqueSlug(trx, slugify(input.company_name));
     const passwordHash = await hashPassword(input.password);
     const currency = (input.currency || DEFAULT_CURRENCY).toUpperCase();

     const [companyId] = await trx('companies').insert({
       name: input.company_name, slug, contact_name: input.contact_name, email,
       phone: input.phone, country, currency, plan: PLANS.LITE, status: 'active',
     });
     const [userId] = await trx('users').insert({
       company_id: companyId, name: input.contact_name, email,
       password_hash: passwordHash, role: ROLES.MANAGER, active: true,
     });
     const preset = getZonePreset(country).map((z) => ({
       company_id: companyId, name: z.name, default_price: z.default_price,
       is_default: !!z.is_default, active: true,
     }));
     await trx('zones').insert(preset);

     const company = await trx('companies').where({ id: companyId }).first();
     const user = await trx('users').where({ id: userId }).first();
     return { company, user };
   }

   module.exports = { createCompanyWithManager, slugify };
   ```

2. **Refactor `auth.service.registerCompany`** to call this helper (keeps one source of truth):
   - import `createCompanyWithManager` from `../companies/companies.service`;
   - replace the inline transaction body with:
     ```js
     const result = await db.transaction((trx) => createCompanyWithManager(input, trx));
     ```
   - keep the existing `user.registered` emit (PRP-002) and the returned `{ token, user, company }`.
   - You can now delete the now-unused `slugify` duplicate in `auth.service.js` (import from the
     companies service if still referenced).

   > Verify nothing else imported `slugify` from `auth.service`: `grep -rn "auth.service" server/src`.

### Step 2 — Add the admin "create company" endpoint
Open **`server/src/modules/admin/admin.routes.js`**. Add near the other routes:

```js
const db = require('../../db/knex');               // already imported
const { createCompanyWithManager } = require('../companies/companies.service');
const { bus, EVENTS } = require('../../lib/events');
const { created } = require('../../utils/response'); // ensure imported

const createCompanySchema = z.object({
  company_name: z.string().min(2).max(191),
  contact_name: z.string().min(2).max(191),
  email: z.string().email().max(191),
  phone: z.string().min(4).max(64),
  country: z.string().length(2),
  currency: z.string().min(2).max(8).optional(),
  // root may supply a temp password; if omitted we generate one and email it (PRP-009).
  password: z.string().min(8).max(128).optional(),
});

router.post('/companies', asyncHandler(async (req, res) => {
  const input = createCompanySchema.parse(req.body);
  const tempPassword = input.password || generateTempPassword();
  const result = await db.transaction((trx) =>
    createCompanyWithManager({ ...input, password: tempPassword }, trx));

  // Fire event so PRP-009 can email the manager their credentials (fail-soft).
  try {
    bus.emit(EVENTS.COMPANY_CREATED, {
      company: result.company, manager: result.user, tempPassword,
    });
  } catch (e) { console.error('[events] company.created emit failed:', e.message); }

  // Return the temp password ONCE so the root operator can read it out, then it's gone.
  return created(res, { company: result.company, manager: {
    id: result.user.id, name: result.user.name, email: result.user.email,
  }, tempPassword });
}));
```

Add a helper at the bottom of the file (or in `utils`):
```js
function generateTempPassword() {
  // 12 chars, mixed — good enough for a one-time handoff password.
  return Math.random().toString(36).slice(2, 8) + 'A1' + Math.random().toString(36).slice(2, 6);
}
```
Make sure `z`, `asyncHandler`, `requireAuth`, `requireRoot`, `created` are imported at the top
(the file already imports most; add `created` if missing).

### Step 3 — Confirm plan=pro lifts the daily cap
No code needed — `orders.service.getTodayUsage` already returns `cap = null` for `plan='pro'`
(implemented in PRP-001). Just **note it** in your test (Step 6).

### Step 4 — Frontend: "Create company" form for root
In **`client/src/pages/Admin.tsx`**:
1. Add state for a modal/inline form and a `tempPassword` result to display once.
2. Add a **"+ Create company"** button in the topbar that toggles the form.
3. Form fields: company_name, contact_name, email, phone, country (select EG/SA/AE/US), optional password.
4. On submit: `await api('/admin/companies', { method: 'POST', body })` with the **root** token (the
   admin page is already root-only). On success, show a success card with the manager email + the
   returned `tempPassword` and a "Copy" button, then `load()` the list.

   Minimal handler:
   ```tsx
   async function createCompany(e: React.FormEvent) {
     e.preventDefault();
     try {
       const res = await api('/admin/companies', { method: 'POST', body: form });
       setCreated(res.data); // { company, manager, tempPassword }
       load();
     } catch (e: any) { setErr(e.message); }
   }
   ```
   Render the `tempPassword` clearly: *"Share this one-time password with the manager — it won't be
   shown again."*

### Step 5 — Update the Postman collection
Add to the **Admin (root)** folder a new request (see §5 for the exact spec) and **commit the JSON**.

---

## 5. API endpoints & Postman documentation

### POST `/api/admin/companies` (root only) — NEW
Creates a company + its single manager + seeded zones.

- **Auth:** `Authorization: Bearer {{rootToken}}`
- **Headers:** `Content-Type: application/json`
- **Request body**
  ```json
  {
    "company_name": "Nile Express",
    "contact_name": "Layla Said",
    "email": "layla@nile.test",
    "phone": "+201234567890",
    "country": "EG",
    "currency": "EGP",
    "password": "optional-min-8"
  }
  ```
  `currency` and `password` are optional. If `password` is omitted, the server generates a temp one and
  returns it (and emits `company.created` for the email in PRP-009).
- **201 response**
  ```json
  {
    "success": true,
    "data": {
      "company": { "id": 5, "name": "Nile Express", "country": "EG", "plan": "lite", "status": "active" },
      "manager": { "id": 7, "name": "Layla Said", "email": "layla@nile.test" },
      "tempPassword": "k4f9q2A1x7zr"
    }
  }
  ```
- **Errors:** `409` duplicate email · `422` validation · `401/403` not root.

**Postman setup (add to collection):**
- Folder **Admin (root)** → new request **POST /api/admin/companies**.
- URL `{{baseUrl}}/api/admin/companies`; header `Authorization: Bearer {{rootToken}}`.
- Body = the JSON above.
- **Tests** script to capture the new company id for follow-up calls:
  ```js
  const j = pm.response.json();
  if (j.success) pm.collectionVariables.set('companyId', j.data.company.id);
  ```

---

## 6. Manual test / acceptance verification
```bash
B=http://localhost:3000/api
RT=$(curl -s -X POST $B/auth/login -H "Content-Type: application/json" \
  -d '{"email":"admin@shiprex.io","password":"ChangeMe123!"}' | node -pe "JSON.parse(require('fs').readFileSync(0)).data.token")

# Create a company manually
curl -s -X POST $B/admin/companies -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \
  -d '{"company_name":"Nile Express","contact_name":"Layla","email":"layla@nile.test","phone":"+20100","country":"EG"}'

# Log in as the new manager with the returned tempPassword → expect 200 + zones seeded
# Cross-tenant check: manager token + ?company_id=<other> on a tenant route → expect rejection
```
Cleanup:
```bash
"/c/xampp/mysql/bin/mysql.exe" -uroot -h127.0.0.1 shiprexlite -e "DELETE FROM companies WHERE email='layla@nile.test';"
```

**Acceptance criteria**
- [x] Root can create a company; the returned manager can log in with `tempPassword`.
- [x] New company has seeded zones and 0 orders.
- [x] `company.created` event emitted (confirm via temporary log).
- [x] Suspend blocks that company's login; activate restores it (existing behavior, re-verify).
- [ ] Manager A cannot read company B's orders/zones (isolation — see test below).
- [ ] `plan=pro` removes the daily cap for that company (create > 30; not blocked).

## 7. Cross-tenant isolation test (do not skip)
1. Register company A (manager A) and company B (manager B).
2. As manager A, call `GET /api/orders` → only A's orders.
3. As manager A, try to force B's scope: root-only `?company_id=` is ignored for managers (the
   `resolveTenant` middleware locks a manager to their own `company_id`). Confirm A still sees only A.
4. Document the result in the PRP checklist.

---

## 8. Git commits (follow in order)
**Commit 1 — shared company service + auth refactor**
```bash
git add server/src/modules/companies/companies.service.js server/src/modules/auth/auth.service.js
git commit -m "refactor(companies): PRP-003 extract createCompanyWithManager

Move the company+manager+zones transaction into companies.service and reuse it
from auth.registerCompany. Single source of truth for tenant creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

**Commit 2 — admin manual create endpoint**
```bash
git add server/src/modules/admin/admin.routes.js
git commit -m "feat(admin): PRP-003 POST /admin/companies (root manual create)

Root can create a company + single manager + seeded zones; returns a one-time
temp password and emits company.created (for PRP-009 credentials email).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

**Commit 3 — frontend create form**
```bash
git add client/src/pages/Admin.tsx
git commit -m "feat(web): PRP-003 root 'Create company' form

Adds a create-company form to the admin console; shows the one-time temp
password for handoff after creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

**Commit 4 — Postman + PRP status**
```bash
git add PRPs/postman/shiprex-lite.postman_collection.json PRPs/PRP-003-tenancy-root-admin.md PRPs/README.md
git commit -m "docs(prp): PRP-003 postman request + mark done"
```

---

## 9. Done checklist
- [x] `companies.service.js` created; auth refactored to use it; no duplicate logic.
- [x] `POST /admin/companies` works; temp password returned + event emitted.
- [x] Admin UI create form works and shows temp password once.
- [ ] Isolation test passed; pro uncap verified.
- [x] Postman updated; `npm run build:client` passes; test rows cleaned.
- [ ] All commits made; PRP marked Done.
