Files
AgendaPro/server/routes/booking.ts
T
AgendaPro Dev e8d2435cc2 AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
2026-07-25 13:45:53 -06:00

155 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from "express";
import { db } from "../db.ts";
export const bookingRouter = Router();
function publicBusiness(slug: string) {
return db
.prepare(
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct
FROM businesses WHERE slug = ? AND status = 'active'`
)
.get(slug) as any;
}
// Public business info + services + employees
bookingRouter.get("/:slug", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz) return res.status(404).json({ error: "Negocio no encontrado" });
if (!biz.booking_enabled) return res.status(403).json({ error: "Las reservas online están desactivadas" });
const services = db
.prepare(`SELECT id, name, description, category, duration_min, price, color FROM services WHERE business_id = ? AND active = 1 ORDER BY category, name`)
.all(biz.id);
const employees = db
.prepare(`SELECT id, name, role, color FROM employees WHERE business_id = ? AND active = 1 ORDER BY name`)
.all(biz.id);
res.json({ business: { ...biz, currency_symbol: biz.currency_symbol || "$" }, services, employees });
});
// Available slots for a service + employee on a given date
bookingRouter.get("/:slug/slots", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
const serviceId = Number(req.query.service_id);
const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null;
const date = req.query.date as string; // YYYY-MM-DD
if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" });
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any;
if (!service) return res.status(400).json({ error: "Servicio no válido" });
// candidate employees
let candidates: number[];
if (employeeId) {
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId);
if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" });
candidates = [employeeId];
} else {
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
if (candidates.length === 0) {
const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
candidates = any ? [any.id] : [];
}
}
if (candidates.length === 0) return res.json({ slots: [] });
// business hours 09:0020:00, 30-min grid; reject past times
const dayStart = new Date(`${date}T09:00:00`);
const dayEnd = new Date(`${date}T20:00:00`);
const now = new Date();
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
const dur = service.duration_min;
// gather existing appointments that day for these employees
const startOfDayIso = `${date}T00:00:00`;
const endOfDayIso = `${date}T23:59:59`;
const existing = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE business_id = ? AND employee_id IN (${candidates.map(() => "?").join(",")})
AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
)
.all(biz.id, ...candidates, startOfDayIso, endOfDayIso) as any[];
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
const slotStart = t;
const slotEnd = t + dur * 60000;
// find an employee free in this window
for (const empId of candidates) {
const busy = existing.some(
(b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
);
if (!busy) {
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any;
slots.push({
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
iso: new Date(t).toISOString(),
employee_id: empId,
employee_name: emp?.name,
});
break; // one free employee per slot is enough
}
}
if (slots.length >= 40) break;
}
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
});
// Create a booking from the public site (no auth): creates/finds client + appointment
bookingRouter.post("/:slug/book", (req, res) => {
const biz = publicBusiness(req.params.slug);
if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
const { service_id, employee_id, start_at, client } = req.body ?? {};
if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
if (!service) return res.status(400).json({ error: "Servicio no válido" });
const start = new Date(start_at);
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
// resolve employee
let empId = employee_id ? Number(employee_id) : null;
if (!empId) {
const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
empId = cand?.id ?? null;
}
if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
// resolve client (by phone/email or create)
let clientId: number | null = null;
const match = client.phone
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
: client.email
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND email = ?`).get(biz.id, client.email) as any)
: null;
if (match) clientId = match.id;
else {
const created = db
.prepare(`INSERT INTO clients (business_id, name, email, phone, tags) VALUES (?, ?, ?, ?, 'Online') RETURNING id`)
.get(biz.id, client.name, client.email || null, client.phone || null) as any;
clientId = created.id;
}
const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const appt = db
.prepare(
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
)
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;
res.status(201).json({
appointment: {
id: appt.id,
start_at: appt.start_at,
end_at: appt.end_at,
price: appt.price,
service_name: service.name,
employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name,
},
business: { name: biz.name, currency_symbol: biz.currency_symbol },
client_created: !match,
});
});