← back to NationalPaperHangers

ARCHITECTURE_PHASE2.md

104 lines

# Architecture Phase 2 — Cutover Plan

This document is the rip-and-replace plan that follows Phase 1's additive
scaffolding (migration `005_users_roles.sql` + `lib/services/*` +
`lib/auth/policies.js`). Phase 1 changed nothing in the runtime path. Phase 2
moves identity, RBAC, and the booking/subscription side-effects onto the new
files, then drops the redundant columns on `installers`.

## Goals

- `req.session` stores `userId`, not `installerId`.
- `installers.email` and `installers.password_hash` are dropped — identity
  lives only in `users`.
- Ops staff (verification queue, COI/W-9/license review) authenticate without
  a phantom row in `installers`.
- Routes delegate to `lib/services/*`. `routes/*.js` becomes thin glue.

## Cutover order (smallest blast radius first)

1. **Add `attachUser` middleware** in `server.js`, alongside the existing
   `attachInstaller`. It loads `req.user` from `req.session.userId` and
   reads roles via `lib/services/users`. Both middleware run during the
   dual-write window so existing routes keep working.

2. **Switch `routes/auth.js`** (`/login`, `/signup`, `/logout`):
   - `/login` → `users.authenticate()`, sets `req.session.userId`. Keep
     setting `req.session.installerId` for one release as a belt-and-braces.
   - `/signup` → `users.createUser()` + `installers.create({ ownerUserId })`
     + `users.grantRole(uid, 'installer_owner')` + `installers.addMember(iid, uid, 'owner')`.
     Stops writing `installers.email` and `installers.password_hash`.
   - `/logout` unchanged.

3. **Switch `routes/admin.js`** to use `lib/auth/policies.requireInstallerMember`
   in place of `requireInstaller`. Resolve the active installer via
   `users.userInstallers(req.session.userId)` and an `?as=<slug>` param when
   the user owns multiple. Profile updates delegate to `installers.updateProfile`.

4. **Switch `routes/api.js`** booking endpoints to call
   `bookings.createBooking()`, `bookings.confirmBooking()`, etc. Email send
   stays in the route (fire-and-forget after the service returns).

5. **Switch `routes/webhooks.js`** to call
   `subscriptions.applyCheckoutSession`, `applySubscriptionChanged`,
   `applySubscriptionDeleted`. The Stripe-signature verify + audit-log
   idempotency stays in the route (it owns the transaction boundary).

6. **Build the verification ops UI** at `/ops/*` gated by `requireOps`. This
   is the whole reason for the split — RBAC for non-installer staff.

7. **Drop legacy columns** in a final migration `006_drop_installers_identity.sql`:
   ```sql
   ALTER TABLE installers
     DROP COLUMN email,
     DROP COLUMN password_hash,
     DROP COLUMN last_login_at;
   ```
   Only run this once steps 1–6 ship and a full smoke + 1 week of prod
   traffic confirm `users` is the source of truth.

## Keeping smoke tests green throughout

- `tests/smoke.test.js` currently asserts public + claim flows; none touch
  login / billing. They should stay 10/10 after every step above.
- Add an integration test for `/login` against a fixture user in `users`
  before step 2 ships, and one for `/ops/verify` queue before step 6.
- `seed.sql` needs a parallel `users` + `installer_members` block — write it
  alongside step 1, not at the end.

## Rollback story

Steps 1–5 are reversible by reverting the route file (the services keep
working untouched). Step 7 is the one-way door — do not run the column-drop
migration until two on-call rotations have passed without a related incident.

## What this does NOT change

- `lib/db.js`, `lib/booking-token.js`, `lib/csrf.js`, `lib/email.js`,
  `lib/slots.js`, `lib/stripe.js` — unchanged.
- The session store (`connect-pg-simple` against the `session` table) — unchanged.
- Schema for `bookings`, `installer_portfolio`, `installer_availability`,
  `installer_time_off`, `installer_reviews`, `consumer_leads`, `lead_offers`,
  `subscription_events`, `directory_optout`, `scrape_log` — unchanged.

## Decisions for Phase 2

1. **Single-owner per installer — DECIDED YES, 2026-05-05.** Migration 005
   now ships with a partial UNIQUE index
   `uq_installer_members_one_owner ON installer_members (installer_id) WHERE role = 'owner'`.
   v1 NPH assumes one operator per studio: claim flow is domain-restricted
   to one email, billing is one Stripe customer per installer, dashboard
   is single-user. v2 multi-owner work (e.g. an agency operating 5 studios)
   will replace this with a richer `delegations` table — easier than
   retrofitting consistency on top of dual-owner data later.

## Open questions still pending Steve

1. Self-signup grants `installer_owner` automatically. Should ops review
   gate that role transition (claim_status flow), or is it fine for an
   un-verified installer to be an `installer_owner` from minute one?
2. Where do customers live? They aren't installers and won't be in `users`
   under this plan — they show up only as `bookings.customer_email`. If we
   ever want consumer accounts (saved addresses, repeat-customer pricing)
   they'll need their own table or a `users.role = 'customer'` extension.