Initial project import
This commit is contained in:
174
server/cms-server.mjs
Normal file
174
server/cms-server.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
const port = Number(process.env.INQUIRY_API_PORT || 5174);
|
||||
const maxBodyBytes = Number(process.env.INQUIRY_MAX_BODY_BYTES || 100_000);
|
||||
const notificationUrl = String(process.env.INQUIRY_NOTIFICATION_URL || '').trim();
|
||||
const notificationAuthHeader = String(process.env.INQUIRY_NOTIFICATION_AUTH_HEADER || '').trim();
|
||||
const allowedOrigin = String(process.env.INQUIRY_ALLOWED_ORIGIN || '*').trim() || '*';
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const url = new URL(request.url || '/', `http://${request.headers.host || '127.0.0.1'}`);
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
send(response, 204, null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.pathname === '/api/health' && request.method === 'GET') {
|
||||
send(response, 200, {
|
||||
ok: true,
|
||||
service: 'travel-inquiry-api',
|
||||
uptime: Math.round(process.uptime()),
|
||||
notification: {
|
||||
configured: Boolean(notificationUrl),
|
||||
provider: notificationUrl ? 'webhook' : 'none',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/inquiries' && request.method === 'POST') {
|
||||
const inquiry = normalizeInquiry(await readBody(request));
|
||||
await notifyInquiry(inquiry);
|
||||
send(response, 202, {
|
||||
id: inquiry.id,
|
||||
receivedAt: inquiry.receivedAt,
|
||||
status: 'accepted',
|
||||
notification: 'delivered',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send(response, 404, { error: 'Not found' });
|
||||
} catch (error) {
|
||||
const status = error instanceof HttpError ? error.status : 500;
|
||||
if (status >= 500) console.error(error);
|
||||
send(response, status, { error: error instanceof Error ? error.message : 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`Travel inquiry API running at http://127.0.0.1:${port}`);
|
||||
console.log(`Notification webhook: ${notificationUrl ? 'configured' : 'not configured'}`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
async function shutdown() {
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function notifyInquiry(inquiry) {
|
||||
if (!notificationUrl) {
|
||||
throw new HttpError(503, 'Inquiry notification is not configured');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (notificationAuthHeader) headers.Authorization = notificationAuthHeader;
|
||||
|
||||
try {
|
||||
const response = await fetch(notificationUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
type: 'inquiry.submitted',
|
||||
occurredAt: inquiry.receivedAt,
|
||||
data: inquiry,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpError(502, 'Inquiry notification service rejected the request');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw new HttpError(502, 'Inquiry notification service is unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeInquiry(body) {
|
||||
if (!isObject(body)) {
|
||||
throw new HttpError(400, 'Inquiry must be an object');
|
||||
}
|
||||
|
||||
const name = field(body.name, 160);
|
||||
const contact = field(body.contact, 240);
|
||||
if (!name || !contact) {
|
||||
throw new HttpError(400, 'Inquiry name and contact are required');
|
||||
}
|
||||
|
||||
return {
|
||||
id: field(body.id, 120) || makeId('inq'),
|
||||
receivedAt: new Date().toISOString(),
|
||||
name,
|
||||
contact,
|
||||
interest: field(body.interest, 500),
|
||||
travelMonth: field(body.travelMonth, 120),
|
||||
guests: field(body.guests, 120),
|
||||
message: field(body.message, 6_000),
|
||||
locale: body.locale === 'en' ? 'en' : 'zh',
|
||||
};
|
||||
}
|
||||
|
||||
function field(value, maxLength) {
|
||||
return String(value || '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
}
|
||||
|
||||
async function readBody(request) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of request) {
|
||||
size += chunk.length;
|
||||
if (size > maxBodyBytes) {
|
||||
throw new HttpError(413, 'Request body is too large');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const body = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!body) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Request body must be valid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function send(response, status, body) {
|
||||
response.writeHead(status, {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
});
|
||||
response.end(body === null ? '' : JSON.stringify(body));
|
||||
}
|
||||
|
||||
function makeId(prefix) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user