Files
AgendaPro/server/routes/appointments.ts
T
AgendaPro Dev e8d2435cc2 AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
2026-07-25 13:45:53 -06:00

304 lines
12 KiB
TypeScript

import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
export const appointmentsRouter = Router();
/** Compute commission for a ticket: service rate, fallback employee rate. */
function computeCommission(businessId: number, serviceId: number, employeeId: number, amount: number): number {
const svc = db.prepare(`SELECT commission_pct p FROM services WHERE id = ? AND business_id = ?`).get(serviceId, businessId) as { p: number } | undefined;
let pct = svc?.p ?? 0;
if (!pct) {
const emp = db.prepare(`SELECT commission_pct p FROM employees WHERE id = ? AND business_id = ?`).get(employeeId, businessId) as { p: number } | undefined;
pct = emp?.p ?? 0;
}
return Math.round(((amount * pct) / 100) * 100) / 100;
}
function joinAppointment(a: any) {
if (!a) return a;
a.service = db
.prepare(`SELECT id, name, category, color, duration_min, price FROM services WHERE id = ?`)
.get(a.service_id);
a.employee = db
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
.get(a.employee_id);
a.client = db
.prepare(`SELECT id, name, phone, email, tags FROM clients WHERE id = ?`)
.get(a.client_id);
return a;
}
appointmentsRouter.get("/", (req: AuthedRequest, res) => {
const { from, to, employee_id, client_id, status, limit } = req.query;
const conds: string[] = ["business_id = ?"];
const args: any[] = [req.user!.business_id];
if (from) {
conds.push("start_at >= ?");
args.push(String(from));
}
if (to) {
conds.push("start_at <= ?");
args.push(String(to));
}
if (employee_id) {
conds.push("employee_id = ?");
args.push(Number(employee_id));
}
if (client_id) {
conds.push("client_id = ?");
args.push(Number(client_id));
}
if (status) {
conds.push("status = ?");
args.push(String(status));
}
// Employees only see their own appointments (still can see all to plan, but filter by default)
// We let them see all so they can coordinate; UI filters by default.
let sql = `SELECT * FROM appointments WHERE ${conds.join(" AND ")} ORDER BY start_at`;
if (limit) sql += ` LIMIT ${Number(limit)}`;
const rows = db.prepare(sql).all(...args) as any[];
rows.forEach(joinAppointment);
res.json({ appointments: rows });
});
appointmentsRouter.get("/:id", (req: AuthedRequest, res) => {
const a = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!a) return err(res, 404, "Cita no encontrada");
joinAppointment(a);
res.json({ appointment: a });
});
appointmentsRouter.post("/", (req: AuthedRequest, res) => {
const {
service_id,
employee_id,
client_id,
new_client,
start_at,
duration_min,
price_override,
notes,
status,
} = req.body ?? {};
if (!service_id) return err(res, 400, "Selecciona un servicio");
if (!start_at) return err(res, 400, "Selecciona una fecha y hora");
const service = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(Number(service_id), req.user!.business_id) as any;
if (!service) return err(res, 400, "Servicio no válido");
// Resolve client: existing id, or create a new one from new_client payload
let clientId = client_id ? Number(client_id) : null;
if (!clientId && new_client) {
if (!new_client.name) return err(res, 400, "El nombre del cliente es obligatorio");
const created = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
req.user!.business_id,
new_client.name,
new_client.email || null,
new_client.phone || null,
new_client.notes || null,
new_client.tags || null
) as { id: number };
clientId = created.id;
}
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 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;
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);
}
joinAppointment(r);
res.status(201).json({ appointment: r });
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cita no encontrada");
const {
service_id,
employee_id,
client_id,
start_at,
end_at,
duration_min,
status,
price,
notes,
} = req.body ?? {};
let startIso = existing.start_at;
let endIso = existing.end_at;
if (start_at) {
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha inválida");
startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const svc = service_id
? db.prepare(`SELECT duration_min FROM services WHERE id = ?`).get(Number(service_id)) as any
: { duration_min: null };
const dur = Number(duration_min) || svc?.duration_min || null;
if (end_at) {
endIso = new Date(end_at).toISOString().replace(/\.\d{3}Z$/, "Z");
} else if (dur) {
endIso = new Date(start.getTime() + dur * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
} else if (duration_min) {
const start = new Date(existing.start_at);
endIso = new Date(start.getTime() + Number(duration_min) * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const finalStatus = status ?? existing.status;
const finalPrice = price !== undefined ? Number(price) : existing.price;
db.prepare(
`UPDATE appointments
SET service_id = ?, employee_id = ?, client_id = ?, start_at = ?, end_at = ?, status = ?, price = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
service_id !== undefined ? Number(service_id) : existing.service_id,
employee_id !== undefined ? Number(employee_id) : existing.employee_id,
client_id !== undefined ? Number(client_id) : existing.client_id,
startIso,
endIso,
finalStatus,
finalPrice,
notes !== undefined ? notes : existing.notes,
id
);
// If transitioned to completed and no ticket yet, create one
if (finalStatus === "completed" && existing.status !== "completed") {
const hasTicket = db
.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`)
.get(id);
if (!hasTicket) {
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card', ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, commission);
}
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const { tip = 0, payment_method = "card" } = req.body ?? {};
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
if (!hasTicket) {
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, Number(tip), payment_method, commission);
} else {
// ticket already exists (e.g. completed via PATCH) — backfill commission + tip if provided
db.prepare(`UPDATE tickets SET commission = COALESCE(NULLIF(commission,0), ?) WHERE appointment_id = ? AND commission = 0`).run(
computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price),
id
);
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id);
if (!existing) return err(res, 404, "Cita no encontrada");
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
res.json({ ok: true });
});
/** Preview the cancellation policy outcome for an appointment (penalty based on hours left). */
appointmentsRouter.get("/:id/cancellation-policy", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const biz = db
.prepare(`SELECT cancel_window_hours wh, cancel_penalty_pct pen, require_deposit rd, deposit_pct dp FROM businesses WHERE id = ?`)
.get(req.user!.business_id) as any;
const hoursLeft = (new Date(appt.start_at).getTime() - Date.now()) / 3600000;
const withinWindow = hoursLeft < (biz?.wh ?? 24);
const penalty = withinWindow ? Math.round(((appt.price * (biz?.pen ?? 0)) / 100) * 100) / 100 : 0;
res.json({
hours_left: Math.round(hoursLeft),
within_window: withinWindow,
window_hours: biz?.wh ?? 24,
penalty_pct: withinWindow ? biz?.pen ?? 0 : 0,
penalty_amount: penalty,
policy_active: (biz?.pen ?? 0) > 0,
});
});