← back to NationalPaperHangers
lib/services/bookings.js
207 lines
// lib/services/bookings.js
//
// Pure booking service. Extracts the slot-conflict-safe transaction pattern
// out of routes/api.js so it can be reused (admin-side reschedule, partner
// API, batch backfills) without going through Express.
//
// Phase 1: NOT yet imported from routes/api.js. Available for Phase 2 cutover.
//
// Errors are thrown as plain Error objects with `.code` set to one of:
// not_found · installer_not_on_calendar · missing_field · invalid_email
// invalid_range · past_slot · slot_taken
// The route layer maps these to HTTP statuses.
'use strict';
const db = require('../db');
const bookingToken = require('../booking-token');
const REQUIRED = ['customer_name', 'customer_email', 'scheduled_start', 'scheduled_end'];
const PAID_TIERS = new Set(['pro', 'signature', 'enterprise']);
function err(code, status = 400) {
const e = new Error(code);
e.code = code;
e.status = status;
return e;
}
// ─────────────────────────────────────────────────────────────────────────
// Lookup
// ─────────────────────────────────────────────────────────────────────────
async function getByUuid(uuid) {
if (!uuid) return null;
return db.one(
`SELECT b.*, i.slug AS installer_slug, i.business_name AS installer_business_name
FROM bookings b
JOIN installers i ON i.id = b.installer_id
WHERE b.uuid = $1`,
[uuid]
);
}
async function listForInstaller(installerId, { from, to, limit = 200 } = {}) {
if (!installerId) return [];
const fromTs = from || new Date().toISOString();
const toTs = to || new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString();
return db.many(
`SELECT id, uuid, customer_name, customer_email, project_type, status,
scheduled_start, scheduled_end, city, state
FROM bookings
WHERE installer_id = $1
AND scheduled_end >= $2
AND scheduled_start <= $3
ORDER BY scheduled_start
LIMIT $4`,
[installerId, fromTs, toTs, limit]
);
}
// ─────────────────────────────────────────────────────────────────────────
// Create — race-safe via SELECT … FOR UPDATE on the installer row.
//
// Returns: { id, uuid, view_token, installer }
// Caller is responsible for sending confirmation emails.
// ─────────────────────────────────────────────────────────────────────────
async function createBooking(slug, fields) {
// 1. Validate installer + tier (no transaction yet).
const installer = await db.one(
`SELECT id, slug, business_name, email, tier
FROM installers WHERE slug = $1 AND status = 'active'`,
[slug]
);
if (!installer) throw err('not_found', 404);
if (!PAID_TIERS.has(installer.tier)) {
throw err('installer_not_on_calendar', 402);
}
const f = fields || {};
for (const k of REQUIRED) {
if (!f[k]) throw err('missing_' + k, 400);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(f.customer_email))) {
throw err('invalid_customer_email', 400);
}
const start = new Date(f.scheduled_start);
const end = new Date(f.scheduled_end);
if (!(end > start)) throw err('invalid_range', 400);
if (start < new Date()) throw err('past_slot', 400);
// 2. Race-safe insert.
const client = await db.pool.connect();
let booked;
try {
await client.query('BEGIN');
await client.query('SELECT id FROM installers WHERE id = $1 FOR UPDATE', [installer.id]);
const conflict = await client.query(
`SELECT 1 FROM bookings
WHERE installer_id = $1
AND status IN ('pending','confirmed')
AND tstzrange(scheduled_start, scheduled_end) && tstzrange($2::timestamptz, $3::timestamptz)
LIMIT 1`,
[installer.id, start.toISOString(), end.toISOString()]
);
if (conflict.rowCount > 0) {
await client.query('ROLLBACK');
throw err('slot_taken', 409);
}
// Per-buyer Google sign-in identity, if attached. Pulled from the express
// session by the route caller and forwarded as f.consumer_account_id.
var customerRole = ['homeowner','designer','architect','contractor','property_mgr','other']
.includes(String(f.customer_role || '').toLowerCase()) ? f.customer_role : null;
var productSourced = f.product_sourced === true || f.product_sourced === 'true' ? true
: f.product_sourced === false || f.product_sourced === 'false' ? false
: null;
const ins = await client.query(
`INSERT INTO bookings
(installer_id, customer_name, customer_email, customer_phone,
project_type, market_segment, material, brand, brand_sku,
surfaces, rooms, square_feet, roll_count_estimate, ceiling_height_ft,
surface_state, access_constraints, budget_band,
address_line1, address_line2, city, state, zip,
customer_role, product_sourced, consumer_account_id,
scheduled_start, scheduled_end, customer_notes, status, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,'pending','web')
RETURNING id, uuid`,
[
installer.id,
f.customer_name, f.customer_email, f.customer_phone || null,
f.project_type || 'consultation', f.market_segment || null, f.material || null, f.brand || null, f.brand_sku || null,
f.surfaces || null, f.rooms || null,
f.square_feet ? parseInt(f.square_feet, 10) : null,
f.roll_count_estimate ? parseInt(f.roll_count_estimate, 10) : null,
f.ceiling_height_ft ? parseFloat(f.ceiling_height_ft) : null,
f.surface_state || null, f.access_constraints || null, f.budget_band || null,
f.address_line1 || null, f.address_line2 || null, f.city || null,
(f.state || '').toUpperCase() || null, f.zip || null,
customerRole, productSourced, f.consumer_account_id || null,
start.toISOString(), end.toISOString(), f.customer_notes || null
]
);
await client.query('COMMIT');
booked = ins.rows[0];
} catch (e) {
try { await client.query('ROLLBACK'); } catch {}
throw e;
} finally {
client.release();
}
return {
id: booked.id,
uuid: booked.uuid,
view_token: bookingToken.sign(booked.uuid),
installer
};
}
// ─────────────────────────────────────────────────────────────────────────
// State transitions (installer-scoped — caller must verify ownership)
// ─────────────────────────────────────────────────────────────────────────
async function confirmBooking(bookingId, installerId) {
const r = await db.query(
`UPDATE bookings
SET status = 'confirmed', confirmed_at = now()
WHERE id = $1 AND installer_id = $2 AND status = 'pending'`,
[bookingId, installerId]
);
return r.rowCount > 0;
}
async function declineBooking(bookingId, installerId, reason = null) {
const r = await db.query(
`UPDATE bookings
SET status = 'declined', canceled_at = now(), cancel_reason = $3
WHERE id = $1 AND installer_id = $2 AND status IN ('pending','confirmed')`,
[bookingId, installerId, reason]
);
return r.rowCount > 0;
}
async function completeBooking(bookingId, installerId) {
const r = await db.query(
`UPDATE bookings
SET status = 'completed', completed_at = now()
WHERE id = $1 AND installer_id = $2 AND status = 'confirmed'`,
[bookingId, installerId]
);
return r.rowCount > 0;
}
module.exports = {
getByUuid,
listForInstaller,
createBooking,
confirmBooking,
declineBooking,
completeBooking
};