← 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
};