feat(backend): auto-assign + transactional double-booking guard + working-hours slots
POST /book and POST /appointments now resolve the specialist via autoAssign (or isAvailable-guard a chosen one) inside BEGIN IMMEDIATE...COMMIT, returning 409 on conflict. Slots endpoint derives the day window from real working_hours and ranks the best specialist per slot. settings/employees expose + persist the new fields. Adds booking-e2e.mjs and adapts e2e-test.mjs fixtures to in-hours slots.
This commit is contained in:
+134
-74
@@ -1,5 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import { db } from "../db.ts";
|
||||
import {
|
||||
parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
|
||||
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||
type WorkingHoursMap,
|
||||
} from "../lib/scheduling.ts";
|
||||
|
||||
export const bookingRouter = Router();
|
||||
|
||||
@@ -7,7 +12,8 @@ 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
|
||||
booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct,
|
||||
auto_assign_specialist, working_hours
|
||||
FROM businesses WHERE slug = ? AND status = 'active'`
|
||||
)
|
||||
.get(slug) as any;
|
||||
@@ -47,55 +53,75 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
||||
} 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] : [];
|
||||
const anyEmp = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||
candidates = anyEmp ? [anyEmp.id] : [];
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return res.json({ slots: [] });
|
||||
|
||||
// business hours 09:00–20:00, 30-min grid; reject past times
|
||||
const dayStart = new Date(`${date}T09:00:00`);
|
||||
const dayEnd = new Date(`${date}T20:00:00`);
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;
|
||||
|
||||
// Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
|
||||
const useRanking = !employeeId;
|
||||
const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
|
||||
const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();
|
||||
|
||||
// Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
|
||||
const dateObj = new Date(`${date}T12:00:00`);
|
||||
const singleEmpRow = employeeId
|
||||
? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
|
||||
: null;
|
||||
const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
|
||||
const window = employeeId
|
||||
? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
|
||||
: getWorkingHoursForDate(null, bizWh, dateObj);
|
||||
|
||||
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[];
|
||||
if (!window) {
|
||||
return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
}
|
||||
const dayStart = window.startMs;
|
||||
const dayEnd = window.endMs;
|
||||
|
||||
for (let t = dayStart.getTime(); t + dur * 60000 <= dayEnd.getTime(); t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
|
||||
// citas existentes del día para el caso de especialista fijo
|
||||
const existing = employeeId
|
||||
? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
|
||||
: [];
|
||||
|
||||
for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
|
||||
if (t < now.getTime() + 30 * 60000) continue; // pasado / demasiado pronto
|
||||
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()
|
||||
);
|
||||
let chosen: { id: number; name: string } | null = null;
|
||||
if (useRanking) {
|
||||
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
||||
} else {
|
||||
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||
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
|
||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
|
||||
chosen = { id: employeeId, name: emp?.name ?? "" };
|
||||
}
|
||||
}
|
||||
if (chosen) {
|
||||
slots.push({
|
||||
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
|
||||
iso: new Date(t).toISOString(),
|
||||
employee_id: chosen.id,
|
||||
employee_name: chosen.name,
|
||||
});
|
||||
}
|
||||
if (slots.length >= 40) break;
|
||||
}
|
||||
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||
});
|
||||
|
||||
function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||
return aStart < bEnd && aEnd > bStart;
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -107,48 +133,82 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
||||
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;
|
||||
const startMs = start.getTime();
|
||||
const endMs = startMs + service.duration_min * 60000;
|
||||
|
||||
// ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
|
||||
const autoAssignOn = !!biz.auto_assign_specialist;
|
||||
const clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null;
|
||||
|
||||
try {
|
||||
const result = runInTransaction(db, () => {
|
||||
// 1) Resolver especialista
|
||||
let empId: number | null = clientChose;
|
||||
let reasons: string[] = [];
|
||||
if (!empId) {
|
||||
const bizWh = parseWorkingHours(biz.working_hours) as any;
|
||||
const aa = autoAssign(db, {
|
||||
businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||
startMs, endMs, bizWh,
|
||||
});
|
||||
if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
empId = aa.employeeId;
|
||||
reasons = aa.reasons;
|
||||
} else {
|
||||
// validar que ofrece el servicio
|
||||
const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
|
||||
if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
|
||||
// guard anti doble reserva
|
||||
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
|
||||
reasons = ["Tu especialista elegido"];
|
||||
}
|
||||
|
||||
// 2) Resolver cliente (por phone/email o crear)
|
||||
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(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const startIso = new Date(startMs).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;
|
||||
|
||||
const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
|
||||
return { appt, empId: empId as number, empName, reasons, clientCreated: !match };
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
appointment: {
|
||||
id: result.appt.id,
|
||||
start_at: result.appt.start_at,
|
||||
end_at: result.appt.end_at,
|
||||
price: result.appt.price,
|
||||
service_name: service.name,
|
||||
employee_id: result.empId,
|
||||
employee_name: result.empName,
|
||||
reasons: result.reasons,
|
||||
},
|
||||
business: { name: biz.name, currency_symbol: biz.currency_symbol },
|
||||
client_created: result.clientCreated,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e && typeof e === "object" && e.status) {
|
||||
return res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user