import express from 'express' import cors from 'cors' import dotenv from 'dotenv' import { pool } from './db.js' import { sendPostalEmail } from './postal.js' import { sendTelegramMessage } from './telegram.js' import { startTelegramBot } from './telegramBot.js' dotenv.config() const app = express() app.use(cors()) app.use(express.json()) // Helper: combine ISO date (YYYY-MM-DD) and time (HH:mm) into local Date 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) } // Helper: normalize booking row fields to stable strings (avoid timezone surprises) function sanitizeBooking(row) { const normDate = (v) => { if (!v) return v if (typeof v === 'string') { // If it's a plain ISO date (no time), keep as-is const exact = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v) if (exact) return `${exact[1]}-${exact[2]}-${exact[3]}` // If string contains time part, parse and convert to local date components if (v.includes('T')) { 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}` } } // Try generic parse fallback 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') { // Expect 'HH:MM:SS' or 'HH:MM' 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), } } // Simple health app.get('/api/health', (req, res) => res.json({ ok: true })) // List available cargobikes app.get('/api/bikes', async (req, res) => { // fixed bike types const bikes = [1000, 2000, 3000, 4000, 5000] res.json({ bikes }) }) // Create a booking (supports single bike_type or multiple bike_types) app.post('/api/bookings', async (req, res) => { try { const { bike_type, bike_types, start_date, end_date, start_time, end_time, name, email } = req.body const multi = Array.isArray(bike_types) ? bike_types.filter((v) => Number.isFinite(Number(v))).map(Number) : [] const single = Number.isFinite(Number(bike_type)) ? Number(bike_type) : null if ((!single && multi.length === 0) || !start_date || !end_date || !name || !email) { return res.status(400).json({ error: 'Missing fields' }) } // Validate temporal constraints: require start_time/end_time and ensure start >= now, end > start if (!start_time || !end_time) { return res.status(400).json({ error: 'Start and end times are required' }) } const s = parseLocalDateTime(start_date, start_time) const e = parseLocalDateTime(end_date, end_time, true) if (!s || !e) { return res.status(400).json({ error: 'Invalid dates' }) } if (e <= s) { return res.status(400).json({ error: 'End must be after start' }) } const now = new Date() if (s < now) { return res.status(400).json({ error: 'Start cannot be in the past' }) } // Enforce max 31 days in advance for start and end const max = new Date() max.setDate(max.getDate() + 31) max.setHours(23, 59, 59, 999) if (s > max || e > max) { return res.status(400).json({ error: 'Bookings cannot be made more than 31 days in advance' }) } // Explicitly set status to 'pending' to avoid relying solely on DB defaults const text = `INSERT INTO bookings(bike_type, bike_types, start_date, start_time, end_date, end_time, name, email, status) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *` const values = [single, multi.length ? multi.join(',') : null, start_date, start_time || null, end_date, end_time || null, name, email, 'pending'] const result = await pool.query(text, values) // Respond ASAP res.status(201).json({ booking: sanitizeBooking(result.rows[0]) }) // Fire-and-forget: Telegram notify a new request if configured try { const b = sanitizeBooking(result.rows[0]) const bikeList = (() => { const parts = [] if (Number.isFinite(b.bike_type)) parts.push(String(b.bike_type)) if (typeof b.bike_types === 'string' && b.bike_types.trim()) { parts.push(...b.bike_types.split(',').map(s => s.trim()).filter(Boolean)) } return parts.join(', ') })() const rangeLabel = (() => { 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}` })() // Build admin URL only if FRONTEND_URL is absolute (http/https) const base = String(process.env.FRONTEND_URL || '').trim() const hasAbs = /^https?:\/\//i.test(base) const adminUrl = hasAbs ? base.replace(/\/$/, '') + '/admin' : null const msg = `🚲 Nouvelle demande de cargobike\nAssociation: ${escapeHtml(b.name)}\nPériode: ${escapeHtml(rangeLabel)}\nCargobike(s): ${escapeHtml(bikeList || '—')}` const buttons = [ { text: '✅ Accepter', callback_data: `accept:${b.id}` } ] if (adminUrl) buttons.push({ text: '📋 Ouvrir admin', url: adminUrl }) const replyMarkup = { inline_keyboard: [ buttons ] } sendTelegramMessage({ text: msg, parseMode: 'HTML', replyMarkup }).catch((e) => { console.warn('Telegram send failed:', e?.message || e) }) } catch (e) { // Missing config or other error; do not interrupt request flow console.warn('Telegram notify skipped:', e?.message || e) } } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) } }) // List bookings (simple) app.get('/api/bookings', async (req, res) => { try { const result = await pool.query('SELECT * FROM bookings ORDER BY created_at DESC LIMIT 100') res.json({ bookings: result.rows.map(sanitizeBooking) }) } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) } }) // Update booking status app.patch('/api/bookings/:id/status', async (req, res) => { try { const id = Number(req.params.id) if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }) const { status } = req.body || {} const allowed = ['pending', 'accepted', 'refused'] if (!allowed.includes(status)) return res.status(400).json({ error: 'Invalid status' }) // Read current status to enforce immutability once accepted const current = await pool.query('SELECT * FROM bookings WHERE id = $1', [id]) if (current.rowCount === 0) return res.status(404).json({ error: 'Booking not found' }) const prev = current.rows[0] const currStatus = prev.status if (currStatus === 'accepted' && status !== 'accepted') { return res.status(400).json({ error: 'Accepted bookings cannot change status' }) } const result = await pool.query('UPDATE bookings SET status = $1 WHERE id = $2 RETURNING *', [status, id]) if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' }) const updated = result.rows[0] // Respond immediately res.json({ booking: sanitizeBooking(updated) }) // Fire-and-forget: if newly accepted, send confirmation email (do not block response) if (status === 'accepted' && currStatus !== 'accepted') { sendAcceptanceEmail(updated).catch((e) => { console.warn(`Postal email send failed for booking #${id}:`, e?.message || e) }) } } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) } }) // Compose and send acceptance email via Postal async function sendAcceptanceEmail(bookingRow) { const postalEnabled = process.env.POSTAL_URL && process.env.POSTAL_API_KEY && process.env.POSTAL_FROM if (!postalEnabled) { console.warn('Postal not configured (POSTAL_URL/POSTAL_API_KEY/POSTAL_FROM). Skipping email send.') return } const b = sanitizeBooking(bookingRow) // recipients: allow comma or semicolon separated values const recipients = String(b.email || '') .split(/[;,]/) .map(s => s.trim()) .filter(s => s.length > 0) if (recipients.length === 0) { console.warn(`Booking #${b.id} has no recipient email; skipping send.`) return } // bikes list from single or multi const bikeList = (() => { const parts = [] if (Number.isFinite(b.bike_type)) parts.push(String(b.bike_type)) if (typeof b.bike_types === 'string' && b.bike_types.trim()) { parts.push(...b.bike_types.split(',').map(s => s.trim()).filter(Boolean)) } return parts.join(', ') })() const rangeLabel = (() => { 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}` })() const subject = 'AGEP – Confirmation de réservation de cargobike' const greeting = b.name ? `Bonjour ${b.name},` : 'Bonjour,' const text = `${greeting} Votre réservation de cargobike a été validée. Détails de la réservation: - Période: ${rangeLabel} - Cargobike(s): ${bikeList || '—'} Vous recevrez prochainement des informations complémentaires si nécessaire. Cordialement, Association AGEP` const html = `

${escapeHtml(greeting)}

Votre réservation de cargobike a été validée.

Détails de la réservation
• Période: ${escapeHtml(rangeLabel)}
• Cargobike(s): ${escapeHtml(bikeList || '—')}

Vous recevrez prochainement des informations complémentaires si nécessaire.

Cordialement,
Association AGEP

` await sendPostalEmail({ to: recipients, subject, text, html, }) } 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('>','>') } // Update booking fields (non-status) app.patch('/api/bookings/:id', async (req, res) => { try { const id = Number(req.params.id) if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }) const allowed = ['bike_type', 'bike_types', 'start_date', 'start_time', 'end_date', 'end_time', 'name', 'email'] const entries = Object.entries(req.body || {}).filter(([k]) => allowed.includes(k)) if (entries.length === 0) return res.status(400).json({ error: 'No valid fields to update' }) // Get current booking to validate temporal changes const current = await pool.query('SELECT * FROM bookings WHERE id = $1', [id]) if (current.rowCount === 0) return res.status(404).json({ error: 'Booking not found' }) const existing = current.rows[0] // Validate temporal constraints if dates/times are being updated const bodyObj = req.body || {} const startDate = bodyObj.start_date || existing.start_date const startTime = bodyObj.start_time || existing.start_time const endDate = bodyObj.end_date || existing.end_date const endTime = bodyObj.end_time || existing.end_time if (startDate && endDate && startTime && endTime) { const s = parseLocalDateTime(startDate, startTime) const e = parseLocalDateTime(endDate, endTime, true) if (!s || !e) { return res.status(400).json({ error: 'Invalid dates' }) } if (e <= s) { return res.status(400).json({ error: 'End must be after start' }) } // Allow editing up to 1 day in the past const oneDayAgo = new Date() oneDayAgo.setDate(oneDayAgo.getDate() - 1) oneDayAgo.setHours(0, 0, 0, 0) if (s < oneDayAgo) { return res.status(400).json({ error: 'Start cannot be more than 1 day in the past' }) } // Enforce max 31 days in advance for start and end const max = new Date() max.setDate(max.getDate() + 31) max.setHours(23, 59, 59, 999) if (s > max || e > max) { return res.status(400).json({ error: 'Bookings cannot be made more than 31 days in advance' }) } } // Build dynamic update const sets = [] const values = [] for (let i = 0; i < entries.length; i++) { const [k, v] = entries[i] sets.push(`${k} = $${i + 1}`) values.push(v) } values.push(id) const sql = `UPDATE bookings SET ${sets.join(', ')} WHERE id = $${values.length} RETURNING *` const result = await pool.query(sql, values) if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' }) res.json({ booking: sanitizeBooking(result.rows[0]) }) } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) } }) // Delete a booking by id app.delete('/api/bookings/:id', async (req, res) => { try { const id = Number(req.params.id) if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' }) const result = await pool.query('DELETE FROM bookings WHERE id = $1 RETURNING *', [id]) if (result.rowCount === 0) return res.status(404).json({ error: 'Booking not found' }) res.json({ ok: true, booking: sanitizeBooking(result.rows[0]) }) } catch (err) { console.error(err) res.status(500).json({ error: 'Server error' }) } }) // --- ensure DB tables exist (auto-migration for dev) --- async function ensureTables() { const createSql = ` CREATE TABLE IF NOT EXISTS bookings ( id SERIAL PRIMARY KEY, bike_type INTEGER, bike_types TEXT, start_date DATE NOT NULL, start_time TIME, end_date DATE NOT NULL, end_time TIME, name TEXT NOT NULL, email TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMP WITH TIME ZONE DEFAULT now() ); ` try { await pool.query(createSql) // Ensure columns and constraints for backward compatibility await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS start_time TIME;`) await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS end_time TIME;`) await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS bike_types TEXT;`) await pool.query(`ALTER TABLE bookings ADD COLUMN IF NOT EXISTS status TEXT;`) // Ensure default and not-null for status await pool.query(`ALTER TABLE bookings ALTER COLUMN status SET DEFAULT 'pending';`) await pool.query(`UPDATE bookings SET status = 'pending' WHERE status IS NULL;`) await pool.query(`ALTER TABLE bookings ALTER COLUMN status SET NOT NULL;`) // bike_type nullable for multi-type support await pool.query(`ALTER TABLE bookings ALTER COLUMN bike_type DROP NOT NULL;`) console.log('Database: bookings table is present (created/migrated if needed)') } catch (err) { console.warn('Warning: could not ensure bookings table (DB may be unavailable).', err.message) } } const port = process.env.PORT || 3000 // Run migrations (if possible) then start server ;(async () => { await ensureTables() app.listen(port, () => console.log(`Backend listening on port ${port}`)) // Start Telegram bot polling (if configured) startTelegramBot().catch((e) => console.warn('Telegram bot not started:', e?.message || e)) })()