← 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.