agep-cargo/backend/server.js
2025-10-23 15:49:20 +02:00

404 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { pool } from './db.js'
import { sendPostalEmail } from './postal.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)
res.status(201).json({ booking: sanitizeBooking(result.rows[0]) })
} 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') {
try {
await 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 = `
<p>${escapeHtml(greeting)}</p>
<p>Votre réservation de cargobike a été <strong>validée</strong>.</p>
<p><strong>Détails de la réservation</strong><br/>
• Période: ${escapeHtml(rangeLabel)}<br/>
• Cargobike(s): ${escapeHtml(bikeList || '—')}
</p>
<p>Vous recevrez prochainement des informations complémentaires si nécessaire.</p>
<p>Cordialement,<br/>Association AGEP</p>
`
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('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')
}
// 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}`))
})()