← back to NationalPaperHangers
routes/api.js
427 lines
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const rateLimit = require('express-rate-limit');
const db = require('../lib/db');
const slots = require('../lib/slots');
const email = require('../lib/email');
const bookingToken = require('../lib/booking-token');
const roomCapturesLib = require('../lib/room-captures');
const mediaSniff = require('../lib/media-sniff');
const stripe = require('../lib/stripe');
const { clockMinutes } = require('../lib/utils');
const { requireInstaller } = require('../lib/auth');
const router = express.Router();
// Parse a possibly-garbage form value to a finite number, or null. The
// booking endpoint accepts a directly-posted body, so a non-numeric
// square_feet / ceiling_height_ft / roll_count_estimate would otherwise
// reach an INTEGER/NUMERIC column as NaN and 500 the INSERT.
function numOrNull(v) {
if (v === undefined || v === null || v === '') return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
// =======================================================================
// PUBLIC API
// =======================================================================
// ---- Customer booking-media uploads (room photos/video, wallpaper images) ---
// Used by the /book room-capture flow. Unauthenticated by necessity — the
// customer has no account — so it's IP-rate-limited, content-addressed
// (sha256) so the client can never influence the path, and the stored
// extension is derived from the file's magic bytes — never the spoofable
// browser-supplied Content-Type.
const BOOKING_UPLOAD_ROOT = path.join(__dirname, '..', 'public', 'uploads', 'bookings');
const MEDIA_EXT = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp',
'image/avif': '.avif', 'image/heic': '.heic', 'image/heif': '.heif',
'video/mp4': '.mp4', 'video/quicktime': '.mov', 'video/webm': '.webm'
};
const mediaUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 40 * 1024 * 1024, files: 1 },
fileFilter: (req, file, cb) => {
if (!MEDIA_EXT[file.mimetype]) return cb(new Error('UNSUPPORTED_TYPE'));
cb(null, true);
}
});
// 40 uploads/hour/IP covers a multi-room session (photo + video + wallpaper
// per wall) while making bulk abuse expensive.
const mediaLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 40,
standardHeaders: true, legacyHeaders: false,
message: { ok: false, error: 'Upload limit reached — try again in an hour.' }
});
// POST /api/booking-media — stores one image or short video, returns its URL.
router.post('/booking-media', mediaLimiter, (req, res) => {
mediaUpload.single('file')(req, res, (err) => {
if (err) {
const msg = err.message === 'UNSUPPORTED_TYPE'
? 'File must be a JPG/PNG/WebP/HEIC image or an MP4/MOV/WebM video.'
: (err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 40 MB).' : 'Upload failed.');
return res.status(400).json({ ok: false, error: msg });
}
if (!req.file) return res.status(400).json({ ok: false, error: 'No file received.' });
// Authoritative gate: the extension comes from the actual file bytes, not
// the client's Content-Type. A text/binary blob mislabelled image/png
// clears multer's fileFilter but fails here.
const ext = mediaSniff.sniffExt(req.file.buffer);
if (!ext) {
return res.status(400).json({
ok: false,
error: 'File contents are not a supported image or video.'
});
}
try {
const hash = crypto.createHash('sha256').update(req.file.buffer).digest('hex').slice(0, 24);
fs.mkdirSync(BOOKING_UPLOAD_ROOT, { recursive: true });
const fpath = path.join(BOOKING_UPLOAD_ROOT, hash + ext);
if (!fs.existsSync(fpath)) fs.writeFileSync(fpath, req.file.buffer);
res.json({
ok: true,
url: `/uploads/bookings/${hash}${ext}`,
kind: mediaSniff.isVideoExt(ext) ? 'video' : 'image'
});
} catch (e) {
res.status(500).json({ ok: false, error: 'Could not store file.' });
}
});
});
// GET /api/installers/:slug/slots?from=YYYY-MM-DD&to=YYYY-MM-DD&duration=60
router.get('/installers/:slug/slots', async (req, res, next) => {
try {
const installer = await db.one(
`SELECT id, tier FROM installers WHERE slug = $1 AND status = 'active'`,
[req.params.slug]
);
if (!installer) return res.status(404).json({ error: 'not_found' });
const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
if (!calendarEnabled) return res.json({ slots: [], calendarEnabled: false });
const from = req.query.from || new Date().toISOString().slice(0,10);
const toDate = new Date(); toDate.setDate(toDate.getDate() + 30);
const to = req.query.to || toDate.toISOString().slice(0,10);
const duration = parseInt(req.query.duration || '60', 10);
const list = await slots.availableSlots(installer.id, {
startDate: from, endDate: to,
slotMinutes: duration, bufferMinutes: 15
});
res.json({ slots: list, calendarEnabled: true });
} catch (err) { next(err); }
});
// POST /api/installers/:slug/book
//
// Race-safe: opens a transaction, takes a row-level lock on the installer
// (`SELECT … FOR UPDATE`), re-checks slot availability, then inserts. Two
// concurrent POSTs serialize on that lock, so they cannot both pass the
// conflict check.
router.post('/installers/:slug/book', async (req, res, next) => {
const client = await db.pool.connect();
let booked = null;
let installer = null;
let roomCaptures = [];
try {
// Read installer (no transaction yet) just to validate existence + tier.
installer = await db.one(
`SELECT id, slug, business_name, email, tier, website,
stripe_account_id, stripe_account_charges_enabled
FROM installers WHERE slug = $1 AND status = 'active'`,
[req.params.slug]
);
if (!installer) {
client.release();
return res.status(404).json({ error: 'not_found' });
}
const calendarEnabled = ['pro','signature','enterprise'].includes(installer.tier);
if (!calendarEnabled) {
client.release();
return res.status(402).json({ error: 'installer_not_on_calendar' });
}
const f = req.body || {};
const required = ['customer_name','customer_email','scheduled_start','scheduled_end'];
for (const k of required) {
if (!f[k]) { client.release(); return res.status(400).json({ error: 'missing_' + k }); }
}
// Basic email shape check (fuller validation deferred to send-side).
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(f.customer_email))) {
client.release();
return res.status(400).json({ error: 'invalid_customer_email' });
}
const start = new Date(f.scheduled_start);
const end = new Date(f.scheduled_end);
if (!(end > start)) { client.release(); return res.status(400).json({ error: 'invalid_range' }); }
if (start < new Date()) { client.release(); return res.status(400).json({ error: 'past_slot' }); }
await client.query('BEGIN');
// Serialize booking creates for this installer on the installer row.
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');
client.release();
return res.status(409).json({ error: 'slot_taken' });
}
// The conflict check above only proves the slot isn't double-booked. It
// does NOT prove the requested window is inside the installer's published
// hours — a direct POST could otherwise book 3am on a day they don't work,
// or land inside a time-off block. Reject anything outside availability.
const withinHours = await slots.isWithinAvailability(installer.id, start, end);
if (!withinHours) {
await client.query('ROLLBACK');
client.release();
return res.status(409).json({ error: 'slot_unavailable' });
}
// Structured booking brief (UX idea #6) — validate enums, coerce numerics.
const ALLOWED_SURFACE = new Set([
'new_plaster','painted_drywall','wallpaper_to_remove','brick','wood_panel','other'
]);
const ALLOWED_ACCESS = new Set([
'ground_floor','second_floor','atrium_double_height','high_rise','restricted_hours'
]);
const surfaceState = (f.surface_state && ALLOWED_SURFACE.has(f.surface_state)) ? f.surface_state : null;
const accessCon = (f.access_constraints && ALLOWED_ACCESS.has(f.access_constraints)) ? f.access_constraints : null;
const ceilRaw = numOrNull(f.ceiling_height_ft);
const ceilingHeightFt = ceilRaw === null ? null : Math.max(6, Math.min(40, ceilRaw));
const rollRaw = numOrNull(f.roll_count_estimate);
const rollCountEst = rollRaw === null ? null : Math.max(0, Math.round(rollRaw));
const brandSku = f.brand_sku ? String(f.brand_sku).slice(0, 200) : null;
// 5-question intake additions: who is ordering, has wallpaper been selected,
// and the buyer's signed-in Google account (if they used /auth/google).
const ALLOWED_ROLES = new Set(['homeowner','designer','architect','contractor','property_mgr','other']);
const customerRole = (f.customer_role && ALLOWED_ROLES.has(String(f.customer_role).toLowerCase()))
? String(f.customer_role).toLowerCase() : null;
const productSourced = f.product_sourced === 'true' || f.product_sourced === true ? true
: f.product_sourced === 'false' || f.product_sourced === false ? false
: null;
const consumerAccountId = (req.session && req.session.consumerAccountId) || null;
// Camera-captured rooms (UX #6) — validated against our own upload-path
// shape. When the customer measured walls but left square_feet blank,
// fall back to the captured total so the installer always has a number.
roomCaptures = roomCapturesLib.sanitize(f.room_captures);
const sqftRaw = numOrNull(f.square_feet);
const squareFeet = (sqftRaw !== null && sqftRaw >= 0)
? Math.round(sqftRaw)
: roomCapturesLib.totalSqFt(roomCaptures);
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, budget_band,
ceiling_height_ft, surface_state, access_constraints,
address_line1, address_line2, city, state, zip,
customer_role, product_sourced, consumer_account_id, room_captures,
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::jsonb,$27,$28,$29,'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, brandSku,
f.surfaces || null, f.rooms || null, squareFeet, rollCountEst, f.budget_band || null,
ceilingHeightFt, surfaceState, accessCon,
f.address_line1 || null, f.address_line2 || null, f.city || null, (f.state || '').toUpperCase() || null, f.zip || null,
customerRole, productSourced, consumerAccountId, JSON.stringify(roomCaptures),
start.toISOString(), end.toISOString(), f.customer_notes || null
]
);
await client.query('COMMIT');
booked = ins.rows[0];
} catch (err) {
try { await client.query('ROLLBACK'); } catch {}
client.release();
return next(err);
}
client.release();
const publicUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
const sig = bookingToken.sign(booked.uuid);
// `f` was declared inside the try block above; it's out of scope here,
// but req.body has the same fields. Build booking directly from req.body.
const booking = {
...req.body, id: booked.id, uuid: booked.uuid,
scheduled_start: new Date(req.body.scheduled_start),
scheduled_end: new Date(req.body.scheduled_end),
room_captures: roomCaptures, // sanitized array, not the raw posted string
view_token: sig
};
// Mint deposit Payment Intent. Marketplace cut: NPH retains
// application_fee_amount; balance routes to installer's Connect account
// when present, else holds in platform balance for manual transfer at claim.
// Mocked-mode-safe so dev works without STRIPE_SECRET_KEY.
let deposit = null;
try {
deposit = await stripe.createBookingDepositIntent({
booking, installer,
amountCents: undefined, // env-default
platformFeeBps: undefined
});
await db.query(
`UPDATE bookings
SET stripe_payment_intent_id = $1,
deposit_amount_cents = $2,
deposit_status = 'requires_payment'
WHERE id = $3`,
[deposit.intent_id, deposit.amount_cents, booked.id]
);
} catch (e) {
console.warn('[stripe] deposit intent failed', e.message);
// Booking row stays with NULL deposit. Customer can retry payment from
// the booking page; admin sees deposit_status NULL as a failure indicator.
}
// Fire-and-forget emails. URL carries an HMAC token so the booking page
// isn't browse-by-UUID.
Promise.all([
email.sendEmail({ to: req.body.customer_email, ...email.bookingConfirmationCustomer({ booking, installer, publicUrl }) }),
email.sendEmail({ to: installer.email, ...email.bookingNotificationInstaller({ booking, installer, publicUrl }) })
]).catch(e => console.warn('[email] post-book send failed', e.message));
res.status(201).json({
ok: true,
uuid: booked.uuid,
t: sig,
deposit: deposit ? {
client_secret: deposit.client_secret,
amount_cents: deposit.amount_cents,
application_fee_cents: deposit.application_fee_cents,
mode: deposit.mode,
mocked: !!deposit.mocked
} : null
});
});
// =======================================================================
// INSTALLER-AUTHENTICATED API (calendar editing)
// =======================================================================
router.get('/admin/availability', requireInstaller, async (req, res, next) => {
try {
const rows = await db.many(
`SELECT id, day_of_week, start_time::text AS start_time, end_time::text AS end_time, active
FROM installer_availability WHERE installer_id = $1 ORDER BY day_of_week, start_time`,
[req.installer.id]
);
res.json({ availability: rows });
} catch (err) { next(err); }
});
router.post('/admin/availability', requireInstaller, async (req, res, next) => {
try {
const { day_of_week, start_time, end_time } = req.body;
const dow = parseInt(day_of_week, 10);
if (!(dow >= 0 && dow <= 6)) return res.status(400).json({ error: 'invalid_day' });
if (!start_time || !end_time) return res.status(400).json({ error: 'missing_time' });
// Reject malformed or inverted windows. A garbage time string would 500
// the ::time-cast INSERT; an inverted start/end would save cleanly but
// slots.js silently drops the invalid Interval, so the installer would
// get zero bookable slots with no error to explain why.
const startMin = clockMinutes(start_time);
const endMin = clockMinutes(end_time);
if (startMin === null || endMin === null) return res.status(400).json({ error: 'invalid_time' });
if (endMin <= startMin) return res.status(400).json({ error: 'invalid_time_range' });
const r = await db.one(
`INSERT INTO installer_availability (installer_id, day_of_week, start_time, end_time)
VALUES ($1, $2, $3::time, $4::time)
RETURNING id, day_of_week, start_time::text AS start_time, end_time::text AS end_time, active`,
[req.installer.id, dow, start_time, end_time]
);
res.status(201).json(r);
} catch (err) { next(err); }
});
router.delete('/admin/availability/:id', requireInstaller, async (req, res, next) => {
try {
await db.query(
`DELETE FROM installer_availability WHERE id = $1 AND installer_id = $2`,
[req.params.id, req.installer.id]
);
res.json({ ok: true });
} catch (err) { next(err); }
});
router.get('/admin/time-off', requireInstaller, async (req, res, next) => {
try {
const rows = await db.many(
`SELECT id, start_at, end_at, reason, all_day
FROM installer_time_off WHERE installer_id = $1 AND end_at >= now() ORDER BY start_at`,
[req.installer.id]
);
res.json({ timeOff: rows });
} catch (err) { next(err); }
});
router.post('/admin/time-off', requireInstaller, async (req, res, next) => {
try {
const { start_at, end_at, reason, all_day } = req.body;
if (!start_at || !end_at) return res.status(400).json({ error: 'missing_range' });
const s = new Date(start_at), e = new Date(end_at);
if (isNaN(+s) || isNaN(+e)) return res.status(400).json({ error: 'invalid_date' });
if (!(e > s)) return res.status(400).json({ error: 'invalid_range' });
const r = await db.one(
`INSERT INTO installer_time_off (installer_id, start_at, end_at, reason, all_day)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[req.installer.id, s.toISOString(), e.toISOString(), reason || null, !!all_day]
);
res.status(201).json(r);
} catch (err) { next(err); }
});
router.delete('/admin/time-off/:id', requireInstaller, async (req, res, next) => {
try {
await db.query(
`DELETE FROM installer_time_off WHERE id = $1 AND installer_id = $2`,
[req.params.id, req.installer.id]
);
res.json({ ok: true });
} catch (err) { next(err); }
});
router.get('/admin/bookings.json', requireInstaller, async (req, res, next) => {
try {
const from = req.query.from || new Date().toISOString();
const to = req.query.to || new Date(Date.now() + 90 * 24 * 3600 * 1000).toISOString();
const rows = await 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`,
[req.installer.id, from, to]
);
res.json({ bookings: rows });
} catch (err) { next(err); }
});
module.exports = router;