feat: add email
This commit is contained in:
parent
b6cf4aeb1b
commit
795eb30b0e
5 changed files with 316 additions and 6 deletions
|
|
@ -8,6 +8,7 @@ Setup
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# then edit .env to set DATABASE_URL
|
# then edit .env to set DATABASE_URL
|
||||||
|
# and (optional) Postal settings for email sending
|
||||||
|
|
||||||
2. Install dependencies
|
2. Install dependencies
|
||||||
|
|
||||||
|
|
@ -27,5 +28,46 @@ API
|
||||||
- GET /api/bikes - list available bike types
|
- GET /api/bikes - list available bike types
|
||||||
- POST /api/bookings - create a booking (bike_type, start_date, end_date, name, email)
|
- POST /api/bookings - create a booking (bike_type, start_date, end_date, name, email)
|
||||||
- GET /api/bookings - list recent bookings
|
- 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 <ID> by the returned id); this triggers the Postal email:
|
||||||
|
|
||||||
|
curl -sS -X PATCH http://localhost:3000/api/bookings/<ID>/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.
|
||||||
|
|
|
||||||
92
backend/package-lock.json
generated
92
backend/package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"pg": "^8.11.0"
|
"pg": "^8.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -237,6 +238,15 @@
|
||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
|
@ -397,6 +407,29 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -428,6 +461,18 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
|
|
@ -759,6 +804,44 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.10",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
|
|
@ -1379,6 +1462,15 @@
|
||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"pg": "^8.11.0"
|
"pg": "^8.11.0",
|
||||||
|
"node-fetch": "^3.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
80
backend/postal.js
Normal file
80
backend/postal.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
import { pool } from './db.js'
|
import { pool } from './db.js'
|
||||||
|
import { sendPostalEmail } from './postal.js'
|
||||||
|
|
||||||
dotenv.config()
|
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' })
|
if (!allowed.includes(status)) return res.status(400).json({ error: 'Invalid status' })
|
||||||
|
|
||||||
// Read current status to enforce immutability once accepted
|
// 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' })
|
if (current.rowCount === 0) return res.status(404).json({ error: 'Booking not found' })
|
||||||
const curr = current.rows[0].status
|
const prev = current.rows[0]
|
||||||
if (curr === 'accepted' && status !== 'accepted') {
|
const currStatus = prev.status
|
||||||
|
if (currStatus === 'accepted' && status !== 'accepted') {
|
||||||
return res.status(400).json({ error: 'Accepted bookings cannot change status' })
|
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])
|
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' })
|
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) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
res.status(500).json({ error: 'Server error' })
|
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)
|
// Update booking fields (non-status)
|
||||||
app.patch('/api/bookings/:id', async (req, res) => {
|
app.patch('/api/bookings/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue