← back to NationalPaperHangers
db/migrations/005_users_roles.sql
145 lines
-- 005 · Users / Roles / Installer-Members split (Phase 1, additive only)
--
-- Architectural intent:
-- The `installers` table is currently doing four jobs at once — identity
-- (email + password_hash + last_login_at), directory entity (slug + bio +
-- service area), subscription holder (tier + Stripe IDs), and session actor
-- (req.session.installerId). RBAC for ops staff (verification queue, COI
-- review, license review) has nowhere to live: ops staff aren't installers
-- and shouldn't have a row in `installers`.
--
-- This migration introduces three tables:
--
-- users — pure identity (email, password, login bookkeeping)
-- roles — N-to-N: which roles a user holds globally
-- (admin / ops / installer_owner / installer_member)
-- installer_members — N-to-N: which installer rows a user can act on,
-- and in what capacity ('owner' or 'member')
--
-- Phase 1 (this file) is ADDITIVE ONLY:
-- - Existing routes still read installers.email / installers.password_hash.
-- - Existing sessions still set req.session.installerId.
-- - New tables are populated via the backfill block at the bottom so the
-- two worlds stay in sync.
--
-- Phase 2 (later) will:
-- - Switch /login + /signup to write users/roles/installer_members.
-- - Switch req.session to set userId (not installerId).
-- - Drop installers.email + installers.password_hash + installers.last_login_at.
--
-- This file is idempotent — re-runnable. Apply manually:
-- psql -d national_paper_hangers -f db/migrations/005_users_roles.sql
BEGIN;
-- =======================================================================
-- USERS — pure identity
-- =======================================================================
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- =======================================================================
-- ROLES — global capabilities a user holds
-- =======================================================================
--
-- 'admin' — full superuser (Steve)
-- 'ops' — verification ops staff (COI / W-9 / license queue)
-- 'installer_owner' — controls one or more installer profiles
-- 'installer_member' — works under an installer profile (e.g. crew lead)
CREATE TABLE IF NOT EXISTS roles (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
granted_at TIMESTAMPTZ DEFAULT now(),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (user_id, role),
CHECK (role IN ('admin','ops','installer_owner','installer_member'))
);
CREATE INDEX IF NOT EXISTS idx_roles_role ON roles(role);
-- =======================================================================
-- INSTALLER_MEMBERS — N-to-N user ↔ installer
-- =======================================================================
--
-- The `role` column here is local to the installer (NOT the global role
-- list above). Values: 'owner' | 'member'. An owner can edit the installer
-- profile, manage subscription, invite/remove members. A member can manage
-- bookings and calendar but not billing.
CREATE TABLE IF NOT EXISTS installer_members (
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'owner',
added_at TIMESTAMPTZ DEFAULT now(),
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (installer_id, user_id),
CHECK (role IN ('owner','member'))
);
CREATE INDEX IF NOT EXISTS idx_installer_members_user ON installer_members(user_id);
CREATE INDEX IF NOT EXISTS idx_installer_members_installer ON installer_members(installer_id);
-- Single-owner lock (v1 decision, 2026-05-05).
--
-- v1 NPH assumes one operator per installer studio — the claim flow is
-- domain-restricted to a single email per studio website, billing assumes
-- one Stripe customer per installer, and the dashboard has a single user
-- view. Enforcing this at the DB layer prevents the entire class of bugs
-- where two "owners" disagree about subscription state, profile edits,
-- or who receives booking notifications.
--
-- If/when v2 needs co-owners (e.g. an agency operating 5 studios), drop
-- this index and replace with a richer `delegations` table — easier than
-- retrofitting consistency on top of dual-owner data later.
CREATE UNIQUE INDEX IF NOT EXISTS uq_installer_members_one_owner
ON installer_members (installer_id)
WHERE role = 'owner';
-- =======================================================================
-- BACKFILL — copy current installers identity into the new tables
-- =======================================================================
--
-- For every existing installers row that has email + password_hash, ensure:
-- 1. A users row exists (matched on email).
-- 2. That user holds the 'installer_owner' global role.
-- 3. The user is linked to the installer via installer_members as 'owner'.
--
-- Idempotent: ON CONFLICT DO NOTHING on every insert. Safe to re-run after
-- new self-signups during the dual-write window.
INSERT INTO users (email, password_hash, name, created_at, last_login_at)
SELECT i.email, i.password_hash, COALESCE(i.contact_name, i.business_name),
i.created_at, i.last_login_at
FROM installers i
WHERE i.email IS NOT NULL
AND i.password_hash IS NOT NULL
ON CONFLICT (email) DO NOTHING;
INSERT INTO roles (user_id, role)
SELECT u.id, 'installer_owner'
FROM users u
JOIN installers i ON i.email = u.email
ON CONFLICT (user_id, role) DO NOTHING;
INSERT INTO installer_members (installer_id, user_id, role)
SELECT i.id, u.id, 'owner'
FROM installers i
JOIN users u ON u.email = i.email
ON CONFLICT (installer_id, user_id) DO NOTHING;
COMMIT;
-- NOTE: We are NOT dropping installers.email or installers.password_hash here.
-- Phase 2 will, once routes/auth.js, lib/auth.js, and routes/admin.js have
-- been switched to read from users + installer_members. See ARCHITECTURE_PHASE2.md.