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
+29 -3
View File
@@ -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");
+2
View File
@@ -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": {
+33 -28
View File
@@ -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 {
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" };
// 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( .prepare(
`INSERT INTO appointments `INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id) (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
) )
.get( .get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
req.user!.business_id,
Number(service_id),
empId,
clientId,
startIso,
endIso,
finalStatus,
price,
notes || null,
req.user!.id
) as any;
if (finalStatus === "completed") { if (finalStatus === "completed") {
db.prepare( db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method) `INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')` VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, r.id, clientId, empId, Number(service_id), price); ).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
} }
joinAppointment(inserted);
joinAppointment(r); return inserted;
});
res.status(201).json({ appointment: r }); 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;
}
}); });
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => { appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
+104 -44
View File
@@ -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:0020: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;
chosen = { id: employeeId, name: emp?.name ?? "" };
}
}
if (chosen) {
slots.push({ slots.push({
time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }), time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
iso: new Date(t).toISOString(), iso: new Date(t).toISOString(),
employee_id: empId, employee_id: chosen.id,
employee_name: emp?.name, employee_name: chosen.name,
}); });
break; // one free employee per slot is enough
}
} }
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,15 +133,37 @@ 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;
empId = cand?.id ?? null;
}
if (!empId) return res.status(400).json({ error: "Sin especialista disponible" });
// resolve client (by phone/email or create) // ¿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; let clientId: number | null = null;
const match = client.phone const match = client.phone
? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any) ? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
@@ -130,8 +178,8 @@ bookingRouter.post("/:slug/book", (req, res) => {
clientId = created.id; clientId = created.id;
} }
const endIso = new Date(start.getTime() + service.duration_min * 60000).toISOString().replace(/\.\d{3}Z$/, "Z"); const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z"); const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
const appt = db const appt = db
.prepare( .prepare(
`INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes) `INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
@@ -139,16 +187,28 @@ bookingRouter.post("/:slug/book", (req, res) => {
) )
.get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any; .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({ res.status(201).json({
appointment: { appointment: {
id: appt.id, id: result.appt.id,
start_at: appt.start_at, start_at: result.appt.start_at,
end_at: appt.end_at, end_at: result.appt.end_at,
price: appt.price, price: result.appt.price,
service_name: service.name, service_name: service.name,
employee_name: (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name, employee_id: result.empId,
employee_name: result.empName,
reasons: result.reasons,
}, },
business: { name: biz.name, currency_symbol: biz.currency_symbol }, business: { name: biz.name, currency_symbol: biz.currency_symbol },
client_created: !match, 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;
}
}); });
+18 -9
View File
@@ -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 (?, ?)`);
+11 -2
View File
@@ -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 });
}); });
+64
View File
@@ -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);