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