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:
+29
-3
@@ -13,6 +13,19 @@ function check(name, cond, extra = "") {
|
|||||||
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
else { fail++; console.log(` ✗ ${name} ${extra}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the first available slot (iso) for a service over the next `maxDays` days.
|
||||||
|
// Slots already filter out non-working days and double-booked specialists, so any
|
||||||
|
// returned iso is safe to use as start_at for either autoAssign or self-assign paths.
|
||||||
|
async function findSlot(slug, serviceId, maxDays = 30) {
|
||||||
|
for (let i = 1; i <= maxDays; i++) {
|
||||||
|
const d = new Date(Date.now() + i * 86400000);
|
||||||
|
const date = d.toISOString().slice(0, 10);
|
||||||
|
const { json } = await req("GET", `/public/${slug}/slots?service_id=${serviceId}&date=${date}`);
|
||||||
|
if (Array.isArray(json.slots) && json.slots.length > 0) return json.slots[0].iso;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// /me now works (the bug we fixed) — login first to get a real token
|
// /me now works (the bug we fixed) — login first to get a real token
|
||||||
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||||
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
check("login owner", !!login.token && login.user?.role === "owner", JSON.stringify(login).slice(0, 100));
|
||||||
@@ -20,6 +33,11 @@ const t = login.token;
|
|||||||
const { json: me } = await req("GET", "/auth/me", null, t);
|
const { json: me } = await req("GET", "/auth/me", null, t);
|
||||||
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
|
check("/api/auth/me returns user", me.user?.email === "[email protected]", JSON.stringify(me).slice(0, 100));
|
||||||
|
|
||||||
|
// Slug del negocio (para consultar slots públicos)
|
||||||
|
const { json: settings } = await req("GET", "/settings", null, t);
|
||||||
|
const slug = settings.settings?.slug;
|
||||||
|
check("tiene slug", !!slug, String(slug));
|
||||||
|
|
||||||
const checks = [
|
const checks = [
|
||||||
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
["business", "/business", (j) => j.business?.name === "Lumière Estética & Spa"],
|
||||||
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
|
["employees+stats", "/employees", (j) => j.employees?.length === 6 && !!j.employees[0].stats],
|
||||||
@@ -42,13 +60,21 @@ for (const [name, path, cond] of checks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: "2026-09-15T11:00:00Z" }, t);
|
const slotOwner = await findSlot(slug, 1);
|
||||||
|
check("found slot for service 1", !!slotOwner);
|
||||||
|
const { json: created } = await req("POST", "/appointments", { service_id: 1, client_id: 1, start_at: slotOwner }, t);
|
||||||
check("create appointment", !!created.appointment?.id);
|
check("create appointment", !!created.appointment?.id);
|
||||||
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
const { json: completed } = await req("POST", `/appointments/${created.appointment.id}/complete`, { tip: 50, payment_method: "cash" }, t);
|
||||||
check("complete -> ticket", completed.appointment?.status === "completed");
|
check("complete -> ticket", completed.appointment?.status === "completed");
|
||||||
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
const { json: empLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||||
const { json: empAppt } = await req("POST", "/appointments", { service_id: 5, client_id: 2, start_at: "2026-09-20T10:00:00Z" }, empLogin.token);
|
// Mateo offers services "Corte caballero" (5) and "Corte + arreglo de barba" (6).
|
||||||
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id);
|
// Try 5 first (keeps the original assertion intent); fall back to 6 if 5 has no slots.
|
||||||
|
let empSvcId = 5;
|
||||||
|
let slotEmp = await findSlot(slug, empSvcId);
|
||||||
|
if (!slotEmp) { empSvcId = 6; slotEmp = await findSlot(slug, empSvcId); }
|
||||||
|
check("found slot for employee service", !!slotEmp);
|
||||||
|
const { json: empAppt } = await req("POST", "/appointments", { service_id: empSvcId, client_id: 2, start_at: slotEmp }, empLogin.token);
|
||||||
|
check("employee auto-assign", empAppt.appointment?.employee_id === empLogin.user?.employee_id, JSON.stringify(empAppt).slice(0, 150));
|
||||||
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
const { status: badLogin } = await req("POST", "/auth/login", { email: "[email protected]", password: "wrong" });
|
||||||
check("bad login 401", badLogin === 401);
|
check("bad login 401", badLogin === 401);
|
||||||
const { status: noAuth } = await req("GET", "/employees");
|
const { status: noAuth } = await req("GET", "/employees");
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"seed": "tsx server/scripts/seed.ts",
|
"seed": "tsx server/scripts/seed.ts",
|
||||||
"test:e2e": "node e2e-test.mjs",
|
"test:e2e": "node e2e-test.mjs",
|
||||||
"test:admin": "node admin-test.mjs",
|
"test:admin": "node admin-test.mjs",
|
||||||
|
"test:unit": "node --import tsx --test server/lib/scheduling.test.ts",
|
||||||
|
"test:booking": "node server/scripts/booking-e2e.mjs",
|
||||||
"audit:visual": "node visual-audit.mjs"
|
"audit:visual": "node visual-audit.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Router } from "express";
|
|||||||
import { db } from "../db.ts";
|
import { db } from "../db.ts";
|
||||||
import type { AuthedRequest } from "../lib/auth.ts";
|
import type { AuthedRequest } from "../lib/auth.ts";
|
||||||
import { err } from "../lib/auth.ts";
|
import { err } from "../lib/auth.ts";
|
||||||
|
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
|
||||||
|
|
||||||
export const appointmentsRouter = Router();
|
export const appointmentsRouter = Router();
|
||||||
|
|
||||||
@@ -114,56 +115,60 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => {
|
|||||||
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
|
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
|
// 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);
|
const start = new Date(start_at);
|
||||||
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
|
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
|
||||||
const dur = Number(duration_min) || service.duration_min;
|
const dur = Number(duration_min) || service.duration_min;
|
||||||
const end = new Date(start.getTime() + dur * 60000);
|
const end = new Date(start.getTime() + dur * 60000);
|
||||||
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
|
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
const startIso = start.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 finalStatus = status || "scheduled";
|
||||||
const price = price_override !== undefined ? Number(price_override) : service.price;
|
const price = price_override !== undefined ? Number(price_override) : service.price;
|
||||||
|
|
||||||
const r = db
|
try {
|
||||||
.prepare(
|
const r = runInTransaction(db, () => {
|
||||||
`INSERT INTO appointments
|
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
|
||||||
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
|
let empId = employee_id ? Number(employee_id) : null;
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
|
||||||
)
|
empId = req.user!.employee_id;
|
||||||
.get(
|
}
|
||||||
req.user!.business_id,
|
if (!empId) {
|
||||||
Number(service_id),
|
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
|
||||||
empId,
|
const aa = autoAssign(db, {
|
||||||
clientId,
|
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
|
||||||
startIso,
|
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
|
||||||
endIso,
|
});
|
||||||
finalStatus,
|
empId = aa?.employeeId ?? null;
|
||||||
price,
|
}
|
||||||
notes || null,
|
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
|
||||||
req.user!.id
|
|
||||||
) as any;
|
|
||||||
|
|
||||||
if (finalStatus === "completed") {
|
// Guard anti doble reserva
|
||||||
db.prepare(
|
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
|
||||||
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
|
const inserted = db
|
||||||
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price);
|
.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) => {
|
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
|
||||||
|
|||||||
+134
-74
@@ -1,5 +1,10 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { db } from "../db.ts";
|
import { db } from "../db.ts";
|
||||||
|
import {
|
||||||
|
parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
|
||||||
|
pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction,
|
||||||
|
type WorkingHoursMap,
|
||||||
|
} from "../lib/scheduling.ts";
|
||||||
|
|
||||||
export const bookingRouter = Router();
|
export const bookingRouter = Router();
|
||||||
|
|
||||||
@@ -7,7 +12,8 @@ function publicBusiness(slug: string) {
|
|||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
|
`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'`
|
FROM businesses WHERE slug = ? AND status = 'active'`
|
||||||
)
|
)
|
||||||
.get(slug) as any;
|
.get(slug) as any;
|
||||||
@@ -47,55 +53,75 @@ bookingRouter.get("/:slug/slots", (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
|
candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
|
||||||
if (candidates.length === 0) {
|
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;
|
const anyEmp = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
|
||||||
candidates = any ? [any.id] : [];
|
candidates = anyEmp ? [anyEmp.id] : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidates.length === 0) return res.json({ slots: [] });
|
if (candidates.length === 0) return res.json({ slots: [] });
|
||||||
|
|
||||||
// business hours 09:00–20:00, 30-min grid; reject past times
|
const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;
|
||||||
const dayStart = new Date(`${date}T09:00:00`);
|
|
||||||
const dayEnd = new Date(`${date}T20:00:00`);
|
// 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 now = new Date();
|
||||||
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
|
||||||
const dur = service.duration_min;
|
const dur = service.duration_min;
|
||||||
|
|
||||||
// gather existing appointments that day for these employees
|
if (!window) {
|
||||||
const startOfDayIso = `${date}T00:00:00`;
|
return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
||||||
const endOfDayIso = `${date}T23:59:59`;
|
}
|
||||||
const existing = db
|
const dayStart = window.startMs;
|
||||||
.prepare(
|
const dayEnd = window.endMs;
|
||||||
`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) {
|
// citas existentes del día para el caso de especialista fijo
|
||||||
if (t < now.getTime() + 30 * 60000) continue; // skip past / too-soon
|
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 slotStart = t;
|
||||||
const slotEnd = t + dur * 60000;
|
const slotEnd = t + dur * 60000;
|
||||||
// find an employee free in this window
|
let chosen: { id: number; name: string } | null = null;
|
||||||
for (const empId of candidates) {
|
if (useRanking) {
|
||||||
const busy = existing.some(
|
chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
|
||||||
(b) => b.employee_id === empId && slotStart < new Date(b.end_at).getTime() && slotEnd > new Date(b.start_at).getTime()
|
} else {
|
||||||
);
|
const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any;
|
const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
|
||||||
slots.push({
|
chosen = { id: employeeId, name: emp?.name ?? "" };
|
||||||
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 (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;
|
if (slots.length >= 40) break;
|
||||||
}
|
}
|
||||||
res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
|
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
|
// Create a booking from the public site (no auth): creates/finds client + appointment
|
||||||
bookingRouter.post("/:slug/book", (req, res) => {
|
bookingRouter.post("/:slug/book", (req, res) => {
|
||||||
const biz = publicBusiness(req.params.slug);
|
const biz = publicBusiness(req.params.slug);
|
||||||
@@ -107,48 +133,82 @@ bookingRouter.post("/:slug/book", (req, res) => {
|
|||||||
const start = new Date(start_at);
|
const start = new Date(start_at);
|
||||||
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
|
if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });
|
||||||
|
|
||||||
// resolve employee
|
const startMs = start.getTime();
|
||||||
let empId = employee_id ? Number(employee_id) : null;
|
const endMs = startMs + service.duration_min * 60000;
|
||||||
if (!empId) {
|
|
||||||
const cand = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ? LIMIT 1`).get(Number(service_id)) as any;
|
// ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
|
||||||
empId = cand?.id ?? null;
|
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,11 +73,12 @@ employeesRouter.get("/:id", (req: AuthedRequest, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||||
const { name, role, email, phone, color, service_ids } = req.body ?? {};
|
const { name, role, email, phone, color, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||||
if (!name) return err(res, 400, "El nombre es obligatorio");
|
if (!name) return err(res, 400, "El nombre es obligatorio");
|
||||||
const r = db
|
const r = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO employees (business_id, name, role, email, phone, color) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`
|
`INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
req.user!.business_id,
|
req.user!.business_id,
|
||||||
@@ -85,7 +86,10 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
|||||||
role || "Especialista",
|
role || "Especialista",
|
||||||
email || null,
|
email || null,
|
||||||
phone || null,
|
phone || null,
|
||||||
color || "#3b66ff"
|
color || "#3b66ff",
|
||||||
|
JSON.stringify(Array.isArray(specialties) ? specialties : []),
|
||||||
|
working_hours ? (typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours)) : null,
|
||||||
|
typeof efficiency_score === "number" ? efficiency_score : 50
|
||||||
) as any;
|
) as any;
|
||||||
if (Array.isArray(service_ids)) {
|
if (Array.isArray(service_ids)) {
|
||||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||||
@@ -98,11 +102,9 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
|||||||
|
|
||||||
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = db
|
const existing = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id as number) as any;
|
||||||
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
|
|
||||||
.get(id, req.user!.business_id as number) as any;
|
|
||||||
if (!existing) return err(res, 404, "Empleado no encontrado");
|
if (!existing) return err(res, 404, "Empleado no encontrado");
|
||||||
const { name, role, email, phone, color, active, service_ids } = req.body ?? {};
|
const { name, role, email, phone, color, active, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
|
||||||
const merged = {
|
const merged = {
|
||||||
name: name ?? existing.name,
|
name: name ?? existing.name,
|
||||||
role: role ?? existing.role,
|
role: role ?? existing.role,
|
||||||
@@ -110,10 +112,17 @@ employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
|||||||
phone: phone ?? existing.phone,
|
phone: phone ?? existing.phone,
|
||||||
color: color ?? existing.color,
|
color: color ?? existing.color,
|
||||||
active: active === undefined ? existing.active : active ? 1 : 0,
|
active: active === undefined ? existing.active : active ? 1 : 0,
|
||||||
|
specialties: specialties !== undefined
|
||||||
|
? (typeof specialties === "string" ? specialties : JSON.stringify(Array.isArray(specialties) ? specialties : []))
|
||||||
|
: existing.specialties,
|
||||||
|
working_hours: working_hours !== undefined
|
||||||
|
? (working_hours === null ? null : typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours))
|
||||||
|
: existing.working_hours,
|
||||||
|
efficiency_score: efficiency_score !== undefined ? Number(efficiency_score) : (existing.efficiency_score ?? 50),
|
||||||
};
|
};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ? WHERE id = ?`
|
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ?, specialties = ?, working_hours = ?, efficiency_score = ? WHERE id = ?`
|
||||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, id);
|
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, merged.specialties, merged.working_hours, merged.efficiency_score, id);
|
||||||
if (Array.isArray(service_ids)) {
|
if (Array.isArray(service_ids)) {
|
||||||
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
|
db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
|
||||||
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ const FIELDS = [
|
|||||||
"require_deposit",
|
"require_deposit",
|
||||||
"deposit_pct",
|
"deposit_pct",
|
||||||
"timezone",
|
"timezone",
|
||||||
|
"auto_assign_specialist",
|
||||||
|
"working_hours",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
settingsRouter.get("/", (req: AuthedRequest, res) => {
|
settingsRouter.get("/", (req: AuthedRequest, res) => {
|
||||||
const b = db
|
const b = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
|
`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
|
||||||
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone
|
cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone,
|
||||||
|
auto_assign_specialist, working_hours
|
||||||
FROM businesses WHERE id = ?`
|
FROM businesses WHERE id = ?`
|
||||||
)
|
)
|
||||||
.get(req.user!.business_id) as any;
|
.get(req.user!.business_id) as any;
|
||||||
@@ -39,6 +42,12 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
|||||||
for (const f of FIELDS) {
|
for (const f of FIELDS) {
|
||||||
if (body[f] !== undefined) merged[f] = body[f];
|
if (body[f] !== undefined) merged[f] = body[f];
|
||||||
}
|
}
|
||||||
|
if (merged.working_hours && typeof merged.working_hours !== "string") {
|
||||||
|
merged.working_hours = JSON.stringify(merged.working_hours);
|
||||||
|
}
|
||||||
|
if (merged.auto_assign_specialist !== undefined) {
|
||||||
|
merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0;
|
||||||
|
}
|
||||||
// slug uniqueness check
|
// slug uniqueness check
|
||||||
if (merged.slug !== undefined) {
|
if (merged.slug !== undefined) {
|
||||||
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
merged.slug = String(merged.slug).trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||||
@@ -51,7 +60,7 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
|||||||
merged.id = req.user!.business_id;
|
merged.id = req.user!.business_id;
|
||||||
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
|
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
|
||||||
const updated = db
|
const updated = db
|
||||||
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone FROM businesses WHERE id = ?`)
|
.prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone, auto_assign_specialist, working_hours FROM businesses WHERE id = ?`)
|
||||||
.get(req.user!.business_id);
|
.get(req.user!.business_id);
|
||||||
res.json({ settings: updated });
|
res.json({ settings: updated });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
|
||||||
|
const BASE = "http://localhost:5173/api";
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
async function req(method, path, body, token) {
|
||||||
|
const headers = { "Content-Type": "application/json" };
|
||||||
|
if (token) headers.authorization = `Bearer ${token}`;
|
||||||
|
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||||
|
const text = await res.text();
|
||||||
|
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||||
|
return { status: res.status, json };
|
||||||
|
}
|
||||||
|
function check(name, cond, extra = "") {
|
||||||
|
if (cond) { pass++; console.log(` \u2713 ${name}`); }
|
||||||
|
else { fail++; console.log(` \u2717 ${name} ${extra}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
|
||||||
|
const t = login.token;
|
||||||
|
check("login owner", !!t);
|
||||||
|
|
||||||
|
// 1) Slug del negocio
|
||||||
|
const { json: settings } = await req("GET", "/settings", null, t);
|
||||||
|
const slug = settings.settings?.slug;
|
||||||
|
check("tiene slug", !!slug, String(slug));
|
||||||
|
|
||||||
|
// 2) Datos públicos + servicios
|
||||||
|
const { json: pub } = await req("GET", `/public/${slug}`);
|
||||||
|
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
|
||||||
|
const svc = pub.services[0];
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000);
|
||||||
|
const date = tomorrow.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// 3) Slots disponibles
|
||||||
|
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
|
||||||
|
check("slots devuelve lista", Array.isArray(slotsRes.slots));
|
||||||
|
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);
|
||||||
|
|
||||||
|
// 4) Reservar (auto-asignación, sin employee_id)
|
||||||
|
const slot = slotsRes.slots[0];
|
||||||
|
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
|
||||||
|
service_id: svc.id,
|
||||||
|
start_at: slot.iso,
|
||||||
|
client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
|
||||||
|
});
|
||||||
|
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
|
||||||
|
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);
|
||||||
|
|
||||||
|
// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
|
||||||
|
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
|
||||||
|
service_id: svc.id,
|
||||||
|
employee_id: booked.appointment.employee_id,
|
||||||
|
start_at: slot.iso,
|
||||||
|
client: { name: "Cliente Conflictivo", phone: "+525500000002" },
|
||||||
|
});
|
||||||
|
check("doble reserva → 409", conflict === 409, `status=${conflict}`);
|
||||||
|
|
||||||
|
// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
|
||||||
|
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
|
||||||
|
const { json: pub2 } = await req("GET", `/public/${slug}`);
|
||||||
|
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
|
||||||
|
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup
|
||||||
|
|
||||||
|
console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
Reference in New Issue
Block a user