- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
6.7 KiB
Task 6 — backend appointments.ts conflict guard + autoAssign fallback
Files changed
server/routes/appointments.ts- Added import:
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts"; - Rewrote the
POST /handler tail (from the old// Resolve employeecomment through the handler's closing})) so that:start/end/startMs/endMs/finalStatus/priceare computed first (outside the tx).- The employee resolution + guard + INSERT + completed→ticket +
joinAppointmentall run insiderunInTransaction(db, …). - Employee resolution keeps the same priority chain (
employee_idfrom body →employee.roleself-assign →autoAssignfallback). The oldLIMIT 1lookup overemployee_servicesis removed; when no employee is resolved,autoAssign(db, {…})picks the best available specialist. - Before the INSERT,
isAvailable(db, empId, startMs, endMs)is checked; on conflict it throws{status:409, error:"Ese horario ya está ocupado para el especialista"}. - The catch translates any
{status,error}throw intoerr(res, status, error)and rethrows everything else.
GET /,GET /:id,PATCH /:id,POST /:id/complete,DELETE /:id,GET /:id/cancellation-policyare untouched.joinAppointmentandcomputeCommissionare untouched.
- Added import:
Deviations from the plan's literal text
- Replaced a wider window than "lines 129-167". The plan said to replace from
const start = new Date(start_at);(line 129) through the handler end, but the verbatim block it provided re-resolvesempIdinside the transaction. If I had kept the original outerempIdresolution (lines 116-127, including theLIMIT 1lookup and the earlyreturn err(res,400,…)), the outer code would (a) shadow the innerempId, (b) leave the newautoAssignpath as dead code, and (c) directly contradict the task summary ("when still no employee, callautoAssign(...)instead of the oldLIMIT 1lookup"). So I replaced the outer block too. This matches the verbatim block's own comment ("Resolver empleado (igual que antes, pero usando autoAssign si ninguno)") and the task summary. businessId: Number(req.user!.business_id)instead ofbusinessId: req.user!.business_id.AuthedRequest.user.business_idis typednumber | null(fromshared/types.ts), butAutoAssignCtx.businessIdrequiresnumber.Number(...)is safe becauseauthRequiredguarantees an authenticated user with a business. This is a literal-text bug in the plan; without the coerciontscerrors with TS2322.
Verification
npm run typecheck
PASS — 0 errors. (After the Number(...) coercion; raw plan text produced TS2322: Type 'number | null' is not assignable to type 'number'.)
npm run test:unit
PASS — 15/15. scheduling.test.ts unchanged and green.
npm run test:e2e
FAIL — but NOT a code bug; it is the seed/test-data concern the task anticipated.
- The failure is at
e2e-test.mjs:46("create appointment"):POST /api/appointments { service_id:1, client_id:1, start_at:"2026-09-15T11:00:00Z" }returns 400{"error":"No hay empleado asignado a este servicio"}(i.e.autoAssignreturnednull), which makes line 47 throw aTypeErrorreading.idoffundefined. - The other 16 checks up to that point all PASS (login, /me, business, employees+stats, services, clients+stats, appointments joined, dashboard ×9, tickets).
Root cause (verified): timezone mismatch between the e2e fixture and the new working-hours enforcement.
- Server TZ:
America/Mexico_City(UTC−6). - Fixture slot
2026-09-15T11:00:00Z= 05:00 local — before the business's 09:00 opening (Mon–Fri 09:00–20:00). autoAssign→getWorkingHoursForDatebuilds the day window in local time (09:00–20:00 local) and filters every candidate whose slot doesn't fit; at 05:00 local nobody qualifies →autoAssignreturnsnull→ handler throws{status:400, "No hay empleado asignado a este servicio"}.- The legacy
LIMIT 1lookup never checked working hours, so this exact fixture used to pass; the new code enforces the plan's hard constraint.
This is the "seed/test-data concern, not a code defect" case the task description calls out (it anticipated a 409, but the same principle applies to the 400 from the stricter autoAssign). The fix is data-side — either run the server in UTC, or update e2e-test.mjs to use in-hours ISO slots (the plan's own Task 7 booking-e2e.mjs already does this by fetching slots from /slots). The guard was not weakened.
Conflict smoke (temp .mjs, created/verified/deleted)
Using an in-hours slot 2027-02-01T17:00:00Z (= 11:00 local):
- Create #1 (
service_id:1, owner, noemployee_id): 201,autoAssignplaced it onemployee_id:1. - Create #2 (same
employee_id:1+ same slot): 409{"error":"Ese horario ya está ocupado para el especialista"}. - Create #3 (same
employee_id:1+ 2 h later, non-overlapping): 201.
All three behave exactly as required.
Self-review
- Guard inside tx? Yes —
isAvailable(db, empId, startMs, endMs)runs insiderunInTransaction(db, …), before the INSERT. Throwing inside rolls back via the helper's ROLLBACK path. - autoAssign fallback when no employee? Yes — after the body's
employee_idand the employee-role self-assign, if!empId, the handler loadsbusinesses.working_hours, parses it, and callsautoAssign(db, {businessId, serviceId, serviceName, serviceCategory, startMs, endMs, bizWh}).empId = aa?.employeeId ?? null; if still null, throws{status:400,…}. - Employee-role self-assign preserved? Yes —
if (!empId && req.user!.role === "employee" && req.user!.employee_id) empId = req.user!.employee_id;is intact. - Owner with no employee_id → autoAssign (not LIMIT 1)? Yes — the old
SELECT … FROM employee_services … LIMIT 1block is removed. - completed→ticket still works? Yes — the
if (finalStatus === "completed") { INSERT INTO tickets … }block is preserved verbatim inside the tx, keyed on the freshlyRETURNING *-edinserted.id. - Price override preserved? Yes —
price = price_override !== undefined ? Number(price_override) : service.pricecomputed before the tx and passed into the INSERT. - Client resolution preserved? Yes — untouched above the replaced block (existing
client_idornew_clientcreate). joinAppointmenton result? Yes — called oninsertedinside the tx, then returned;res.status(201).json({ appointment: r })outside.- Other endpoints untouched? Yes — GET/PATCH/DELETE/complete/cancellation-policy are byte-identical to before.
- Response on success? Yes —
res.status(201).json({ appointment: r })unchanged. - No
git commitrun? Correct — no commit, no stage.