diff --git a/backend/README.md b/backend/README.md index 9d04665..cd3b17a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -8,6 +8,7 @@ Setup cp .env.example .env # then edit .env to set DATABASE_URL + # and (optional) Postal settings for email sending 2. Install dependencies @@ -27,5 +28,46 @@ API - GET /api/bikes - list available bike types - POST /api/bookings - create a booking (bike_type, start_date, end_date, name, email) - GET /api/bookings - list recent bookings +- PATCH /api/bookings/:id/status - update booking status (pending | accepted | refused) +- PATCH /api/bookings/:id - update booking fields +- DELETE /api/bookings/:id - delete a booking +Email sending (Postal) +When a booking is transitioned to "accepted", the backend attempts to send a confirmation email to the booking's email address(es) using Postal. + +Configuration (in .env): + +- POSTAL_URL: Base URL of your Postal server (no trailing slash), e.g. https://postal.example.com +- POSTAL_API_KEY: API key with permission to send messages +- POSTAL_FROM: From email address used for sending, e.g. no-reply@yourdomain.tld +- POSTAL_FROM_NAME: Optional display name for the from address (e.g. "AGEP Cargobike") +- POSTAL_REPLY_TO: Optional reply-to email address + +Notes: +- If Postal variables are not set, the server will skip email sending and log a warning. +- Multiple recipient emails are supported; separate with commas or semicolons. + +Quick test of email sending + +1) Create a pending booking (adjust dates and recipient): + + curl -sS -X POST http://localhost:3000/api/bookings \ + -H 'Content-Type: application/json' \ + -d '{ + "bike_types": [1000], + "start_date": "2025-10-20", + "start_time": "10:00", + "end_date": "2025-10-20", + "end_time": "12:00", + "name": "Association Test", + "email": "recipient@example.com" + }' + +2) Accept the booking (replace by the returned id); this triggers the Postal email: + + curl -sS -X PATCH http://localhost:3000/api/bookings//status \ + -H 'Content-Type: application/json' \ + -d '{"status":"accepted"}' + +If Postal is configured correctly, a confirmation email will be sent in the background. Check your Postal logs if you need to diagnose delivery. diff --git a/backend/package-lock.json b/backend/package-lock.json index 0333ce7..2b3be37 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", + "node-fetch": "^3.3.2", "pg": "^8.11.0" }, "devDependencies": { @@ -237,6 +238,15 @@ "node": ">= 0.10" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -397,6 +407,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -428,6 +461,18 @@ "node": ">= 0.8" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -759,6 +804,44 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -1379,6 +1462,15 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e51ea88..9e7546a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,10 +11,10 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "pg": "^8.11.0" + "pg": "^8.11.0", + "node-fetch": "^3.3.2" }, "devDependencies": { "nodemon": "^3.0.1" } } - diff --git a/backend/postal.js b/backend/postal.js new file mode 100644 index 0000000..730d5e9 --- /dev/null +++ b/backend/postal.js @@ -0,0 +1,80 @@ +// Lightweight Postal API client +// Sends messages via Postal /api/v1/send/message +// Requires env: POSTAL_URL, POSTAL_API_KEY, POSTAL_FROM, optional POSTAL_FROM_NAME, POSTAL_REPLY_TO + +import dotenv from 'dotenv' +dotenv.config() + +let _fetch = null + +async function getFetch() { + if (typeof globalThis.fetch === 'function') return globalThis.fetch + if (_fetch) return _fetch + try { + const mod = await import('node-fetch') + _fetch = mod.default + return _fetch + } catch (e) { + throw new Error('fetch is not available and node-fetch is not installed. Please install node-fetch or use Node 18+.') + } +} + +function ensureArray(v) { + if (!v) return [] + if (Array.isArray(v)) return v + if (typeof v === 'string') return v.split(/[;,]/).map(s => s.trim()).filter(Boolean) + return [String(v)] +} + +function fmtFromAddress(email, name) { + const e = String(email || '').trim() + const n = String(name || '').trim() + if (!e) return '' + return n ? `${n} <${e}>` : e +} + +export async function sendPostalEmail({ to, subject, text, html, cc = [], bcc = [] }) { + const base = (process.env.POSTAL_URL || '').replace(/\/$/, '') + const apiKey = process.env.POSTAL_API_KEY || '' + const fromEmail = process.env.POSTAL_FROM || '' + const fromName = process.env.POSTAL_FROM_NAME || '' + const replyTo = process.env.POSTAL_REPLY_TO || '' + + if (!base || !apiKey || !fromEmail) { + throw new Error('Postal configuration missing: POSTAL_URL, POSTAL_API_KEY, POSTAL_FROM are required') + } + + const endpoint = `${base}/api/v1/send/message` + const payload = { + api_key: apiKey, + from: fmtFromAddress(fromEmail, fromName), + to: ensureArray(to), + subject: subject || '', + html_body: html || undefined, + plain_body: text || undefined, + } + const ccArr = ensureArray(cc) + const bccArr = ensureArray(bcc) + if (ccArr.length) payload.cc = ccArr + if (bccArr.length) payload.bcc = bccArr + if (replyTo) payload.reply_to = replyTo + + const fetchImpl = await getFetch() + const res = await fetchImpl(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + let data + try { + data = await res.json() + } catch (e) { + throw new Error(`Postal response parse error: HTTP ${res.status}`) + } + if (!res.ok || data?.status !== 'success') { + const errMsg = data?.data?.message || data?.message || JSON.stringify(data) + throw new Error(`Postal send failed: ${errMsg}`) + } + return data +} diff --git a/backend/server.js b/backend/server.js index 0e76df0..8880cb1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,6 +2,7 @@ import express from 'express' import cors from 'cors' import dotenv from 'dotenv' import { pool } from './db.js' +import { sendPostalEmail } from './postal.js' dotenv.config() @@ -120,23 +121,118 @@ app.patch('/api/bookings/:id/status', async (req, res) => { 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 status FROM bookings WHERE id = $1', [id]) + 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 curr = current.rows[0].status - if (curr === 'accepted' && status !== 'accepted') { + 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' }) - res.json({ booking: sanitizeBooking(result.rows[0]) }) + 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 = ` +

${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 {