404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
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('&','&').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}`))
|
||
})()
|