import dotenv from 'dotenv' import { pool } from './db.js' import { getUpdates, answerCallbackQuery, editMessageText, sendTelegramMessage } from './telegram.js' import { sendPostalEmail } from './postal.js' dotenv.config() function toFR(iso) { if (!iso) return '' const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(iso)) if (!m) return String(iso) return `${m[3]}/${m[2]}/${m[1]}` } function escapeHtml(s) { return String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>') } function parseLocalDateTime(dateStr, timeStr, end = false) { if (!dateStr) return null const [y, m, d] = String(dateStr).split('-').map(Number) let hh = 0, mm = 0 if (typeof timeStr === 'string' && timeStr.includes(':')) { const [h, mi] = timeStr.split(':').map(Number) hh = Number.isFinite(h) ? h : 0 mm = Number.isFinite(mi) ? mi : 0 } else if (end) { hh = 23; mm = 59 } if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null return new Date(y, m - 1, d, hh, mm, 0, 0) } function sanitizeBooking(row) { const normDate = (v) => { if (!v) return v if (typeof v === 'string') { const exact = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v) if (exact) return `${exact[1]}-${exact[2]}-${exact[3]}` const d = new Date(v) if (!isNaN(d.getTime())) { const yyyy = d.getFullYear() const mm = String(d.getMonth() + 1).padStart(2, '0') const dd = String(d.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } return v } if (v instanceof Date) { const yyyy = v.getFullYear() const mm = String(v.getMonth() + 1).padStart(2, '0') const dd = String(v.getDate()).padStart(2, '0') return `${yyyy}-${mm}-${dd}` } return v } const normTime = (v) => { if (!v) return v if (typeof v === 'string') { const parts = v.split(':') if (parts.length >= 2) return `${parts[0].padStart(2,'0')}:${parts[1].padStart(2,'0')}` return v } return v } return { ...row, start_date: normDate(row.start_date), end_date: normDate(row.end_date), start_time: normTime(row.start_time), end_time: normTime(row.end_time), } } function getBikeTypes(row) { const types = [] if (Number.isFinite(row.bike_type)) types.push(Number(row.bike_type)) if (typeof row.bike_types === 'string' && row.bike_types.trim()) { for (const s of row.bike_types.split(',').map(x => x.trim()).filter(Boolean)) { const n = Number(s) if (Number.isFinite(n)) types.push(n) } } return Array.from(new Set(types)) } function overlaps(aStart, aEnd, bStart, bEnd) { return aStart < bEnd && bStart < aEnd } async function hasConflict(target) { const s = parseLocalDateTime(target.start_date, target.start_time) const e = parseLocalDateTime(target.end_date, target.end_time, true) const types = getBikeTypes(target) if (!s || !e || !types.length) return false const result = await pool.query( `SELECT * FROM bookings WHERE status = 'accepted' AND end_date >= $1 AND start_date <= $2`, [target.start_date, target.end_date] ) const candidates = result.rows.map(sanitizeBooking) for (const c of candidates) { const cs = parseLocalDateTime(c.start_date, c.start_time) const ce = parseLocalDateTime(c.end_date, c.end_time, true) if (!cs || !ce) continue if (!overlaps(s, e, cs, ce)) continue const cTypes = getBikeTypes(c) if (cTypes.some(t => types.includes(t))) return true } return false } function formatRangeLabel(b) { const sDate = b.start_date const eDate = b.end_date const st = (b.start_time && b.start_time.length) ? b.start_time : '00:00' const et = (b.end_time && b.end_time.length) ? b.end_time : '23:59' if (sDate && eDate && sDate === eDate) return `${toFR(sDate)} ${st} → ${et}` return `${toFR(sDate)} ${st} → ${toFR(eDate)} ${et}` } function formatDetails(b) { const bikeList = getBikeTypes(b).join(', ') || '—' const rangeLabel = formatRangeLabel(b) return `\nAssociation: ${escapeHtml(b.name || '—')}\nPériode: ${escapeHtml(rangeLabel)}\nCargobike(s): ${escapeHtml(bikeList)}` } async function acceptBooking(id) { const current = await pool.query('SELECT * FROM bookings WHERE id = $1', [id]) if (current.rowCount === 0) return { ok: false, reason: 'not_found' } const booking = sanitizeBooking(current.rows[0]) if (booking.status === 'accepted') return { ok: true, already: 'accepted', booking } if (booking.status === 'refused') return { ok: true, already: 'refused', booking } if (await hasConflict(booking)) { // Do NOT auto-refuse. Just report conflict with details. return { ok: false, reason: 'conflict', booking } } // Accept and send confirmation email const upd = await pool.query('UPDATE bookings SET status = $1 WHERE id = $2 RETURNING *', ['accepted', id]) const b = sanitizeBooking(upd.rows[0]) try { // Compose and send Postal email const bikeList = getBikeTypes(b).join(', ') const rangeLabel = formatRangeLabel(b) const recipients = String(b.email || '') .split(/[;,]/) .map(s => s.trim()) .filter(s => s.length > 0) if (recipients.length) { const subject = 'AGEP – Confirmation de réservation de cargobike' const greeting = b.name ? `Bonjour ${b.name},` : 'Bonjour,' const text = `${greeting}\n\nVotre réservation de cargobike a été validée.\n\nDétails de la réservation:\n- Période: ${rangeLabel}\n- Cargobike(s): ${bikeList || '—'}\n\nVous recevrez prochainement des informations complémentaires si nécessaire.\n\nCordialement,\nAssociation AGEP` const html = `\n
${escapeHtml(greeting)}
\nVotre réservation de cargobike a été validée.
\nDétails de la réservation
\n • Période: ${escapeHtml(rangeLabel)}
\n • Cargobike(s): ${escapeHtml(bikeList || '—')}\n
Vous recevrez prochainement des informations complémentaires si nécessaire.
\nCordialement,
Association AGEP