← back to NationalPaperHangers

lib/services/installers.js

203 lines

// lib/services/installers.js
//
// Pure installer-directory service. Reads/writes the `installers` and the
// new `installer_members` table.
//
// Phase 1: NOT yet imported from any runtime route. Available for Phase 2
// cutover (replacing inline UPDATE calls in routes/admin.js).
//
// Sanitization helpers (sanitizeWebsite, clampLen) are duplicated from
// routes/admin.js intentionally — Phase 2 will remove the originals when
// the route delegates to this service.

'use strict';

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

// ─────────────────────────────────────────────────────────────────────────
// Sanitization helpers (mirrors routes/admin.js)
// ─────────────────────────────────────────────────────────────────────────

function sanitizeWebsite(input) {
  // Returns a safe http(s) URL string, or null. Strips javascript:/data:/etc.
  // Length-capped to 500 to bound DB write size.
  const v = String(input || '').trim();
  if (!v) return null;
  if (v.length > 500) return null;
  let u;
  try { u = new URL(v); } catch { return null; }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
  return u.toString();
}

function clampLen(v, max) {
  const s = String(v == null ? '' : v);
  return s.length > max ? s.slice(0, max) : s;
}

function arrField(s) {
  return String(s || '')
    .split(',')
    .map(x => x.trim())
    .filter(Boolean)
    .slice(0, 50);
}

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

async function findBySlug(slug) {
  if (!slug) return null;
  return db.one(`SELECT * FROM installers WHERE slug = $1`, [slug]);
}

async function findById(id) {
  if (!id) return null;
  return db.one(`SELECT * FROM installers WHERE id = $1`, [id]);
}

// ─────────────────────────────────────────────────────────────────────────
// Create — slug uniqueness handled by appending -2, -3, …
// ─────────────────────────────────────────────────────────────────────────

async function uniqueSlug(baseName) {
  const baseSlug = slugify(baseName || '', { lower: true, strict: true }).slice(0, 60) || 'installer';
  let slug = baseSlug;
  let n = 2;
  // Loop is bounded by how many duplicates we've ever taken; safe for Phase 1 traffic.
  // eslint-disable-next-line no-await-in-loop
  while (await db.one('SELECT id FROM installers WHERE slug = $1', [slug])) {
    slug = `${baseSlug}-${n++}`;
  }
  return slug;
}

async function create({ businessName, contactName, city, state, zip, email = null, passwordHash = null }) {
  // Phase 2 will accept (ownerUserId) and stop writing email/password_hash here.
  // Phase 1 still writes them into installers because routes/auth.js + lib/auth.js
  // are still the source of truth.
  if (!businessName) throw new Error('business_name_required');
  const slug = await uniqueSlug(businessName);

  const row = await db.one(
    `INSERT INTO installers
       (slug, email, password_hash, business_name, contact_name, city, state, zip,
        status, tier, subscription_status)
     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending','basic','inactive')
     RETURNING id, slug`,
    [
      slug,
      email,
      passwordHash,
      clampLen(businessName, 200),
      clampLen(contactName, 120),
      clampLen(city, 80),
      clampLen((state || '').toUpperCase(), 2),
      clampLen(zip, 20)
    ]
  );
  return row;
}

// ─────────────────────────────────────────────────────────────────────────
// Profile update — mirrors routes/admin.js POST /admin/profile
// ─────────────────────────────────────────────────────────────────────────

async function updateProfile(installerId, fields) {
  if (!installerId) throw new Error('installer_id_required');
  const f = fields || {};

  const website = sanitizeWebsite(f.website);
  if (f.website && !website) {
    // Caller decides how to surface this — return a structured error.
    const err = new Error('invalid_website');
    err.code = 'invalid_website';
    throw err;
  }

  await db.query(
    `UPDATE installers SET
       business_name = $2, contact_name = $3, phone = $4, headline = $5, bio = $6,
       city = $7, state = $8, zip = $9, service_radius_miles = $10, travel_available = $11,
       team_size = $12, founded_year = $13, website = $14,
       market_segments = $15, materials = $16, brands_handled = $17, accreditations = $18,
       response_time_hours = $19,
       profile_complete = (LENGTH(COALESCE($6,'')) > 40 AND LENGTH(COALESCE($5,'')) > 0)
     WHERE id = $1`,
    [
      installerId,
      clampLen(f.business_name, 200),
      clampLen(f.contact_name, 120),
      clampLen(f.phone, 40),
      clampLen(f.headline, 200),
      clampLen(f.bio, 4000),
      clampLen(f.city, 80),
      clampLen((f.state || '').toUpperCase(), 2),
      clampLen(f.zip, 20),
      parseInt(f.service_radius_miles || '50', 10),
      f.travel_available === 'on' || f.travel_available === true,
      f.team_size ? parseInt(f.team_size, 10) : null,
      f.founded_year ? parseInt(f.founded_year, 10) : null,
      website,
      arrField(f.market_segments),
      arrField(f.materials),
      arrField(f.brands_handled),
      arrField(f.accreditations),
      parseInt(f.response_time_hours || '24', 10)
    ]
  );
}

// ─────────────────────────────────────────────────────────────────────────
// Membership management
// ─────────────────────────────────────────────────────────────────────────

async function addMember(installerId, userId, role = 'member', addedBy = null) {
  if (!installerId || !userId) throw new Error('ids_required');
  if (role !== 'owner' && role !== 'member') {
    throw new Error(`invalid_membership_role:${role}`);
  }
  await db.query(
    `INSERT INTO installer_members (installer_id, user_id, role, added_by)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (installer_id, user_id) DO UPDATE
       SET role = EXCLUDED.role`,
    [installerId, userId, role, addedBy]
  );
}

async function removeMember(installerId, userId) {
  await db.query(
    `DELETE FROM installer_members WHERE installer_id = $1 AND user_id = $2`,
    [installerId, userId]
  );
}

async function membersOf(installerId) {
  if (!installerId) return [];
  return db.many(
    `SELECT u.id AS user_id, u.email, u.name, im.role AS membership_role, im.added_at
       FROM installer_members im
       JOIN users u ON u.id = im.user_id
      WHERE im.installer_id = $1
      ORDER BY im.added_at`,
    [installerId]
  );
}

module.exports = {
  // helpers exposed for tests / future re-use
  sanitizeWebsite,
  clampLen,
  // public API
  findBySlug,
  findById,
  create,
  updateProfile,
  addMember,
  removeMember,
  membersOf
};