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:
@@ -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) => {
|
||||
|
||||
+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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,11 +73,12 @@ employeesRouter.get("/:id", (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");
|
||||
const r = db
|
||||
.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(
|
||||
req.user!.business_id,
|
||||
@@ -85,7 +86,10 @@ employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
role || "Especialista",
|
||||
email || 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;
|
||||
if (Array.isArray(service_ids)) {
|
||||
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) => {
|
||||
const id = Number(req.params.id);
|
||||
const existing = db
|
||||
.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`)
|
||||
.get(id, req.user!.business_id as number) as any;
|
||||
const existing = db.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");
|
||||
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 = {
|
||||
name: name ?? existing.name,
|
||||
role: role ?? existing.role,
|
||||
@@ -110,10 +112,17 @@ employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
|
||||
phone: phone ?? existing.phone,
|
||||
color: color ?? existing.color,
|
||||
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(
|
||||
`UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ? WHERE id = ?`
|
||||
).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, 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, merged.specialties, merged.working_hours, merged.efficiency_score, id);
|
||||
if (Array.isArray(service_ids)) {
|
||||
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 (?, ?)`);
|
||||
|
||||
@@ -17,13 +17,16 @@ const FIELDS = [
|
||||
"require_deposit",
|
||||
"deposit_pct",
|
||||
"timezone",
|
||||
"auto_assign_specialist",
|
||||
"working_hours",
|
||||
] as const;
|
||||
|
||||
settingsRouter.get("/", (req: AuthedRequest, res) => {
|
||||
const b = 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
|
||||
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) as any;
|
||||
@@ -39,6 +42,12 @@ settingsRouter.patch("/", ownerOnly, (req: AuthedRequest, res) => {
|
||||
for (const f of FIELDS) {
|
||||
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
|
||||
if (merged.slug !== undefined) {
|
||||
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;
|
||||
db.prepare(`UPDATE businesses SET ${sets.join(", ")} WHERE id = @id`).run(merged);
|
||||
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);
|
||||
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