← back to Ventura Corridor

src/server/appointments/router.ts

279 lines

/**
 * Appointment router — mounted in src/server/index.ts before the static
 * handler and before the catch-all 404.  All routes are public except
 * /api/appointments/admin/* which can be admin-gated by the existing
 * basic-auth middleware if needed (we don't add it here so the booking
 * flow stays self-service).
 *
 * Reversibility: every route is namespaced under /api/appointments,
 * /appointments/, /book, /embed/appointments.js.  Removing the mount
 * line in index.ts removes the feature.
 */
import { Router, type Request, type Response } from 'express';
import { query } from '../../../db/pool.ts';
import { renderBookPage, renderConfirmation } from './widget.ts';
import { renderEmbedLoader } from './embed-loader.ts';
import { computeSlots } from './slots.ts';
import {
  buildAuthUrl, exchangeCodeAndStore, isOAuthConfigured,
  getValidAccessToken, revoke,
} from './oauth.ts';
import { insertEvent, deleteEvent } from './calendar.ts';

const router = Router();

// ─── Embed-friendly headers ─────────────────────────────────────────────
// Set permissive CORS + frame headers so the widget can be iframed from
// any origin (Shopify, custom HTML, etc.).  This is safe: every route
// behind it is read-only or accepts public booking input that goes
// through normal validation.
function allowEmbed(_req: Request, res: Response, next: () => void) {
  res.setHeader('Access-Control-Allow-Origin',  '*');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  // helmet's frameguard default is SAMEORIGIN; we override here.
  res.removeHeader('X-Frame-Options');
  res.setHeader('Content-Security-Policy', "frame-ancestors *");
  next();
}
router.use(allowEmbed);

// ─── Health (debug; cheap) ──────────────────────────────────────────────
router.get('/api/appointments/health', async (_req, res) => {
  const cfg = isOAuthConfigured();
  try {
    const r = await query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM appt_availability`);
    res.json({ ok: true, oauth_configured: cfg, availability_rows: parseInt(r.rows[0].c, 10) });
  } catch (e: any) {
    res.status(500).json({ ok: false, error: e.message, oauth_configured: cfg });
  }
});

// ─── Availability: list bookable {slot_start, slot_end} for a date ──────
router.get('/api/appointments/availability', async (req, res) => {
  const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
  const date       = String(req.query.date || '').trim();
  if (!ownerEmail || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
    return res.status(400).json({ ok: false, error: 'owner_email and YYYY-MM-DD date required' });
  }
  try {
    const slots = await computeSlots(ownerEmail, date);
    res.json({ ok: true, owner_email: ownerEmail, date, slots });
  } catch (e: any) {
    console.error('[appointments] availability failed', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ─── Book a slot ────────────────────────────────────────────────────────
router.post('/api/appointments/book', async (req, res) => {
  const body = req.body ?? {};
  const name        = String(body.name        || '').trim().slice(0, 200);
  const email       = String(body.email       || '').trim().toLowerCase().slice(0, 200);
  const phone       = String(body.phone       || '').trim().slice(0, 50) || null;
  const notes       = String(body.notes       || '').trim().slice(0, 2000) || null;
  const ownerEmail  = String(body.owner_email || '').trim().toLowerCase();
  const slotStart   = String(body.slot_start  || '').trim();
  const slotEnd     = String(body.slot_end    || '').trim();
  const businessId  = body.business_id ? Number(body.business_id) : null;

  if (!name || !email || !ownerEmail || !slotStart || !slotEnd) {
    return res.status(400).json({ ok: false, error: 'name, email, owner_email, slot_start, slot_end required' });
  }
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    return res.status(400).json({ ok: false, error: 'invalid email' });
  }
  const s = Date.parse(slotStart); const e = Date.parse(slotEnd);
  if (!isFinite(s) || !isFinite(e) || e <= s) {
    return res.status(400).json({ ok: false, error: 'invalid slot window' });
  }
  if (s < Date.now() - 60_000) {
    return res.status(400).json({ ok: false, error: 'slot is in the past' });
  }

  try {
    // Re-check the slot is still in the computed set (race-protection).
    const date = new Date(s).toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
    const slots = await computeSlots(ownerEmail, date);
    const matches = slots.find(sl => sl.slot_start === new Date(s).toISOString());
    if (!matches) {
      return res.status(409).json({ ok: false, error: 'slot no longer available' });
    }

    const ins = await query<{ id: number; cancel_token: string }>(
      `INSERT INTO appt_appointments
         (name, email, phone, business_id, owner_email, slot_start, slot_end, notes, status)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'confirmed')
       RETURNING id, cancel_token`,
      [name, email, phone, businessId, ownerEmail, new Date(s).toISOString(), new Date(e).toISOString(), notes],
    );
    const row = ins.rows[0];

    // Best-effort calendar push
    let eventId: string | null = null;
    try {
      eventId = await insertEvent(ownerEmail, {
        summary:      `Appointment: ${name}`,
        description:  `Booked via venturacorridor.com Smart Scheduling.\n\n` +
                      `Name:  ${name}\nEmail: ${email}\nPhone: ${phone || '—'}\n\n` +
                      `Notes:\n${notes || '(none)'}`,
        startISO:     new Date(s).toISOString(),
        endISO:       new Date(e).toISOString(),
        attendeeEmail: email,
      });
      if (eventId) {
        await query(`UPDATE appt_appointments SET google_event_id = $1 WHERE id = $2`, [eventId, row.id]);
      }
    } catch (calErr) {
      console.error('[appointments] calendar push failed (non-fatal):', calErr);
    }

    res.json({
      ok: true,
      id: row.id,
      confirmation_url: `/appointments/${row.id}`,
      calendar_pushed:  Boolean(eventId),
    });
  } catch (e: any) {
    console.error('[appointments] book failed', e);
    res.status(500).json({ ok: false, error: e.message });
  }
});

// ─── Cancel ─────────────────────────────────────────────────────────────
// Token-gated: ?token=<cancel_token>  OR  Basic-Auth admin (handled upstream).
router.post('/api/appointments/:id/cancel', async (req, res) => {
  const id    = parseInt(req.params.id, 10);
  const token = String(req.query.token || req.body?.token || '');
  if (!isFinite(id)) return res.status(400).json({ ok: false, error: 'invalid id' });

  const r = await query<{ cancel_token: string; google_event_id: string | null; owner_email: string }>(
    `SELECT cancel_token, google_event_id, owner_email FROM appt_appointments WHERE id = $1`, [id],
  );
  if (!r.rowCount) return res.status(404).json({ ok: false, error: 'not found' });

  const isAdmin = Boolean(req.headers.authorization?.startsWith('Basic ')); // upstream gate validates
  if (!isAdmin && token !== r.rows[0].cancel_token) {
    return res.status(403).json({ ok: false, error: 'invalid cancel token' });
  }
  if (r.rows[0].google_event_id) {
    try { await deleteEvent(r.rows[0].owner_email, r.rows[0].google_event_id); } catch {}
  }
  await query(`UPDATE appt_appointments SET status = 'cancelled' WHERE id = $1`, [id]);
  res.json({ ok: true, id, status: 'cancelled' });
});

// ─── OAuth: start ───────────────────────────────────────────────────────
router.get('/api/appointments/oauth/start', async (req, res) => {
  if (!isOAuthConfigured()) {
    return res.status(503).type('text/plain').send(
      'Smart Scheduling OAuth is not configured.\n' +
      'Set GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET in the project .env, then restart.',
    );
  }
  const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
  if (!ownerEmail) return res.status(400).type('text/plain').send('owner_email query param required');
  const url = await buildAuthUrl(ownerEmail);
  res.redirect(url);
});

// ─── OAuth: callback ────────────────────────────────────────────────────
router.get('/api/appointments/oauth/callback', async (req, res) => {
  const code  = String(req.query.code  || '');
  const state = String(req.query.state || '');
  const err   = String(req.query.error || '');
  if (err)  return res.status(400).type('text/plain').send(`OAuth error: ${err}`);
  if (!code || !state) return res.status(400).type('text/plain').send('missing code/state');
  try {
    const ownerEmail = await exchangeCodeAndStore(code, state);
    res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Connected</title>
<style>body{font-family:'Inter',sans-serif;background:#0a0a0c;color:#f4f1ea;
  min-height:100vh;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
.card{max-width:520px}.kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:#b89968}
h1{font-family:'Cormorant Garamond',serif;font-style:italic;font-size:40px;margin:14px 0 12px}
a{color:#b89968}</style>
<div class="card"><div class="kicker">Smart Scheduling</div>
<h1>Calendar connected.</h1>
<p>${ownerEmail} can now receive bookings against their schedule.</p>
<p style="margin-top:24px"><a href="/book?owner=${encodeURIComponent(ownerEmail)}">View booking page</a></p></div>`);
  } catch (e: any) {
    res.status(400).type('text/plain').send(`Token exchange failed: ${e.message}`);
  }
});

// ─── OAuth: status (used by skill/admin to check) ───────────────────────
router.get('/api/appointments/oauth/status', async (req, res) => {
  const ownerEmail = String(req.query.owner_email || '').trim().toLowerCase();
  if (!ownerEmail) return res.status(400).json({ ok: false, error: 'owner_email required' });
  const tok = await getValidAccessToken(ownerEmail);
  res.json({ ok: true, owner_email: ownerEmail, connected: Boolean(tok),
    oauth_configured: isOAuthConfigured() });
});

// ─── OAuth: revoke (admin / skill convenience) ──────────────────────────
router.post('/api/appointments/oauth/revoke', async (req, res) => {
  const ownerEmail = String(req.body?.owner_email || req.query?.owner_email || '').trim().toLowerCase();
  if (!ownerEmail) return res.status(400).json({ ok: false, error: 'owner_email required' });
  await revoke(ownerEmail);
  res.json({ ok: true, owner_email: ownerEmail, revoked: true });
});

// ─── List appointments (admin only — gated by upstream basic-auth) ──────
router.get('/api/appointments/list', async (req, res) => {
  // Note: admin gate in index.ts doesn't currently cover /api/appointments/list,
  // so we add a minimal local check here.  If ADMIN_PASS is set and a Basic
  // header is missing/invalid, reject.
  const auth = String(req.headers.authorization || '');
  const m = auth.match(/^Basic\s+(.+)$/i);
  const expectedUser = process.env.ADMIN_USER || 'admin';
  const expectedPass = process.env.ADMIN_PASS || '';
  if (!m) {
    res.set('WWW-Authenticate', 'Basic realm="Appointments Admin"');
    return res.status(401).json({ ok: false, error: 'auth required' });
  }
  const decoded = Buffer.from(m[1], 'base64').toString('utf8');
  const [u, p] = decoded.split(':');
  if (u !== expectedUser || p !== expectedPass) {
    return res.status(401).json({ ok: false, error: 'bad credentials' });
  }
  const ownerEmail = req.query.owner_email ? String(req.query.owner_email).trim().toLowerCase() : null;
  const r = await query(
    ownerEmail
      ? `SELECT id, name, email, owner_email, slot_start, slot_end, status, google_event_id, created_at
           FROM appt_appointments WHERE owner_email = $1 ORDER BY slot_start DESC LIMIT 200`
      : `SELECT id, name, email, owner_email, slot_start, slot_end, status, google_event_id, created_at
           FROM appt_appointments ORDER BY slot_start DESC LIMIT 200`,
    ownerEmail ? [ownerEmail] : [],
  );
  res.json({ ok: true, count: r.rowCount, rows: r.rows });
});

// ─── HTML pages ─────────────────────────────────────────────────────────
router.get('/book', (req, res) => {
  const ownerEmail = String(req.query.owner || 'info@designerwallcoverings.com').trim().toLowerCase();
  const embed = String(req.query.embed || '') === '1';
  res.type('html').send(renderBookPage(ownerEmail, embed));
});

router.get('/appointments/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  if (!isFinite(id)) return res.status(400).type('text/plain').send('invalid id');
  const r = await query(
    `SELECT id, name, email, slot_start, slot_end, status, owner_email, notes, google_event_id
       FROM appt_appointments WHERE id = $1`, [id],
  );
  if (!r.rowCount) return res.status(404).type('text/plain').send('appointment not found');
  res.type('html').send(renderConfirmation(r.rows[0] as any));
});

// ─── Embed loader JS ────────────────────────────────────────────────────
router.get('/embed/appointments.js', (req, res) => {
  // Origin = scheme + host so the loader can construct absolute iframe URLs.
  const host  = req.get('host') || '127.0.0.1:9780';
  const proto = (req.headers['x-forwarded-proto'] as string) || (req.secure ? 'https' : 'http');
  const origin = `${proto}://${host}`;
  res.type('application/javascript').send(renderEmbedLoader(origin));
});

export default router;