← back to NationalPaperHangers

lib/services/users.js

171 lines

// lib/services/users.js
//
// Pure user/identity service. Reads/writes the new `users`, `roles`, and
// `installer_members` tables introduced in db/migrations/005_users_roles.sql.
//
// Phase 1: NOT yet imported from any runtime route. Available for Phase 2
// cutover (rewriting /login, /signup, attachInstaller → attachUser).
//
// No Express dependency. No req/res. Pure functions over lib/db. Errors
// bubble up to the caller. bcrypt is the only outside-world dep.

'use strict';

const bcrypt = require('bcrypt');
const db = require('../db');

const SALT_ROUNDS = 12;

// Constant-time bcrypt verify against this dummy hash on miss, so login
// timing doesn't leak whether an email exists. Same pattern as routes/auth.js.
const DUMMY_HASH = '$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidinv';

const VALID_GLOBAL_ROLES = new Set([
  'admin',
  'ops',
  'installer_owner',
  'installer_member'
]);

// ─────────────────────────────────────────────────────────────────────────
// Lookup
// ─────────────────────────────────────────────────────────────────────────

async function findByEmail(email) {
  if (!email) return null;
  const e = String(email).toLowerCase().trim();
  return db.one(
    `SELECT id, email, password_hash, name, created_at, last_login_at
       FROM users
      WHERE email = $1`,
    [e]
  );
}

async function findById(id) {
  if (!id) return null;
  return db.one(
    `SELECT id, email, name, created_at, last_login_at
       FROM users
      WHERE id = $1`,
    [id]
  );
}

// ─────────────────────────────────────────────────────────────────────────
// Create
// ─────────────────────────────────────────────────────────────────────────

async function createUser({ email, password, name }) {
  if (!email || !password) {
    throw new Error('email_and_password_required');
  }
  if (password.length < 8) {
    throw new Error('password_too_short');
  }
  const e = String(email).toLowerCase().trim();
  const hash = await bcrypt.hash(password, SALT_ROUNDS);
  const row = await db.one(
    `INSERT INTO users (email, password_hash, name)
     VALUES ($1, $2, $3)
     RETURNING id, email, name, created_at`,
    [e, hash, name || null]
  );
  return row;
}

// ─────────────────────────────────────────────────────────────────────────
// Authenticate — constant-time even on email miss
// ─────────────────────────────────────────────────────────────────────────

async function authenticate(email, plainPw) {
  const user = await findByEmail(email);
  const hash = (user && user.password_hash) ? user.password_hash : DUMMY_HASH;
  const ok = await bcrypt.compare(plainPw || '', hash);
  if (!user || !ok) return null;

  // Touch last_login_at. Best-effort; auth still succeeds if this fails.
  try {
    await db.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]);
  } catch (e) {
    // Swallow — login should still succeed.
  }

  // Strip password_hash before returning.
  const { password_hash, ...safe } = user;
  return safe;
}

// ─────────────────────────────────────────────────────────────────────────
// Roles (global capabilities)
// ─────────────────────────────────────────────────────────────────────────

async function grantRole(userId, role, grantedBy = null) {
  if (!VALID_GLOBAL_ROLES.has(role)) {
    throw new Error(`invalid_role:${role}`);
  }
  await db.query(
    `INSERT INTO roles (user_id, role, granted_by)
     VALUES ($1, $2, $3)
     ON CONFLICT (user_id, role) DO NOTHING`,
    [userId, role, grantedBy]
  );
}

async function revokeRole(userId, role) {
  if (!VALID_GLOBAL_ROLES.has(role)) {
    throw new Error(`invalid_role:${role}`);
  }
  await db.query(
    `DELETE FROM roles WHERE user_id = $1 AND role = $2`,
    [userId, role]
  );
}

async function userRoles(userId) {
  if (!userId) return [];
  const rows = await db.many(
    `SELECT role FROM roles WHERE user_id = $1 ORDER BY role`,
    [userId]
  );
  return rows.map(r => r.role);
}

async function hasRole(userId, role) {
  const r = await db.one(
    `SELECT 1 FROM roles WHERE user_id = $1 AND role = $2 LIMIT 1`,
    [userId, role]
  );
  return !!r;
}

// ─────────────────────────────────────────────────────────────────────────
// Installer membership lookup
// ─────────────────────────────────────────────────────────────────────────

async function userInstallers(userId) {
  if (!userId) return [];
  return db.many(
    `SELECT i.id, i.slug, i.business_name, i.tier, i.subscription_status,
            i.status, im.role AS membership_role
       FROM installer_members im
       JOIN installers i ON i.id = im.installer_id
      WHERE im.user_id = $1
      ORDER BY i.business_name`,
    [userId]
  );
}

module.exports = {
  findByEmail,
  findById,
  createUser,
  authenticate,
  grantRole,
  revokeRole,
  userRoles,
  hasRole,
  userInstallers,
  VALID_GLOBAL_ROLES
};