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, }); });