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