agep-cargo/backend/server.js
Antoine Pelletier 795eb30b0e feat: add email
2025-10-14 04:58:52 +02:00

327 lines
11 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: 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' })
}
// 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' })
// 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}`))
})()