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:
AgendaPro Dev
2026-07-26 15:59:28 -06:00
parent 811d2a24b2
commit ed608656e0
7 changed files with 301 additions and 126 deletions
+43 -38
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
export const appointmentsRouter = Router();
@@ -114,56 +115,60 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
let empId = employee_id ? Number(employee_id) : null;
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
if (!empId) {
const eligible = db
.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`)
.get(Number(service_id)) as { id: number } | undefined;
empId = eligible?.id ?? null;
}
if (!empId) return err(res, 400, "No hay empleado asignado a este servicio");
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
const dur = Number(duration_min) || service.duration_min;
const end = new Date(start.getTime() + dur * 60000);
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const startMs = start.getTime();
const endMs = end.getTime();
const finalStatus = status || "scheduled";
const price = price_override !== undefined ? Number(price_override) : service.price;
const r = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(
req.user!.business_id,
Number(service_id),
empId,
clientId,
startIso,
endIso,
finalStatus,
price,
notes || null,
req.user!.id
) as any;
try {
const r = runInTransaction(db, () => {
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
let empId = employee_id ? Number(employee_id) : null;
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
if (!empId) {
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
const aa = autoAssign(db, {
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
});
empId = aa?.employeeId ?? null;
}
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
if (finalStatus === "completed") {
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price);
// Guard anti doble reserva
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
const inserted = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
if (finalStatus === "completed") {
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
}
joinAppointment(inserted);
return inserted;
});
res.status(201).json({ appointment: r });
} catch (e: any) {
if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
throw e;
}
joinAppointment(r);
res.status(201).json({ appointment: r });
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {