feat(booking): endpoints publics Option A (choisir un créneau proposé)

Complète le backend RDV 2 voies (les 2 modes ont maintenant leur chemin, tous gardés F + hold 5 min) :
- POST /book/api/hold  : hold EXCLUSIF 5 min à la sélection d'un créneau (Option A) ; release possible ; retour au
  pool à l'expiration (bookingSlots re-soustrait les holds vivants).
- POST /book/api/submit mode:'pick' : le client confirme le créneau choisi → confirmWindow (garde F : si le ticket
  est déjà assigné à un autre tech dans F, enregistré en 'Proposé' au lieu d'écraser).
- (mode:'rank' = Option B « le client propose 3 dispos » inchangé : fitBooking → confirmWindow.)
Routes PUBLIC via le préfixe /book existant (aucun changement server.js). Vérifié live : token bidon → 404 lien invalide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 14:34:00 -04:00
parent 777794cd28
commit 1fc0e903eb

View File

@ -950,6 +950,17 @@ async function handlePublicBooking (req, res, method, path, url) {
const windows = await bookingSlots({ skill, duration: dur, start: todayET(), days: getBookingPolicy().horizon_days || 21, aggregate: true, limit: 60 })
return json(res, 200, { ok: true, job: { location: job.service_location || '', service_type: job.service_type || '', required_skill: skill, duration: dur, scheduled: job.scheduled_date || '' }, windows })
}
// Hold PUBLIC (Option A « on propose, le client choisit ») : à la sélection d'un créneau, on le réserve
// EXCLUSIVEMENT hold_minutes (5). Passé ce délai il retourne au pool (bookingSlots re-soustrait les holds vivants).
if (path === '/book/api/hold' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); if (!b.date || !b.start) return json(res, 400, { ok: false, error: 'date + start requis' })
const key = b.date + '|' + (String(b.start).length >= 5 ? String(b.start).slice(0, 5) : b.start)
if (b.release) { releaseHold(key); return json(res, 200, { ok: true, released: true }) }
const minutes = getBookingPolicy().hold_minutes
addHold(key, minutes)
return json(res, 200, { ok: true, minutes, expires_in_s: minutes * 60 })
}
if (path === '/book/api/submit' && method === 'POST') {
const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
const b = await parseBody(req); const dur = Number(job.duration_h) || 1; const skill = skillForJob(job)
@ -959,6 +970,11 @@ async function handlePublicBooking (req, res, method, path, url) {
await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
}
// Option A : le client CHOISIT un créneau proposé → confirmation immédiate (garde F : si conflit, enregistré en Proposé).
if (b.mode === 'pick' && b.date && b.start) {
const r = await confirmWindow(job.name, b.date, b.start, dur, skill)
return json(res, 200, r)
}
return json(res, 400, { ok: false, error: 'requête invalide' })
}
return json(res, 404, { error: 'not found' })