Files
deflock/api/server.ts
T

349 lines
12 KiB
TypeScript

import './telemetry';
import { otelLogger, SeverityNumber, meter } from './telemetry';
import Fastify, { FastifyInstance, FastifyError } from 'fastify';
declare module 'fastify' {
interface FastifyRequest {
errorHandled?: boolean;
meta?: Record<string, string>;
}
}
function classifyErrorMessage(message: string): string {
const msg = message.toLowerCase();
if (msg.includes('geocode') || msg.includes('nominatim')) return 'upstream_error:nominatim';
if (msg.includes('sponsors') || msg.includes('github')) return 'upstream_error:github';
if (msg.includes('openai') || msg.includes('screening')) return 'upstream_error:openai';
if (msg.includes('zammad') || msg.includes('ticket') || msg.includes('tag')) return 'upstream_error:zammad';
if (msg.includes('turnstile') || msg.includes('siteverify')) return 'upstream_error:turnstile';
return 'internal_error';
}
function classifyError(error: FastifyError): string {
if (error.code === 'FST_ERR_VALIDATION') return 'validation_error';
return classifyErrorMessage(error.message);
}
function classifyByStatus(statusCode: number): string {
if (statusCode === 404) return 'not_found';
if (statusCode === 400) return 'client_error';
if (statusCode === 401 || statusCode === 403) return 'auth_error';
if (statusCode >= 400 && statusCode < 500) return 'client_error';
return 'internal_error';
}
import cors from '@fastify/cors';
import { NominatimClient, NominatimResultSchema } from './services/NominatimClient';
import { classifyGeoQuery } from './services/GeoQueryClassifier';
import { GithubClient, SponsorsResponseSchema } from './services/GithubClient';
import { TurnstileClient } from './services/TurnstileClient';
import { ZammadClient, ContactMessageBodySchema, ContactMessageBody } from './services/ZammadClient';
import { AiScreeningClient } from './services/AiScreeningClient';
import { screenContactSubmission, planZammadActions } from './services/ContactScreeningService';
const start = async () => {
const server: FastifyInstance = Fastify({
logger: {
level: 'error',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
},
},
trustProxy: true,
});
// Global error handler
server.setErrorHandler((error: FastifyError, request, reply) => {
const errorType = classifyError(error);
const statusCode = error.statusCode ?? 500;
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: error.message,
attributes: {
'error.type': errorType,
'http.route': (request.routeOptions as { url?: string })?.url ?? '',
'http.request.method': request.method,
'http.response.status_code': statusCode,
'exception.message': error.message,
'exception.stacktrace': error.stack ?? '',
},
});
request.errorHandled = true;
if (statusCode !== 404) {
server.log.error({
url: request.url,
method: request.method,
error: error.message,
stack: error.stack,
}, 'Request error');
}
reply.status(statusCode).send({ error: 'Internal Server Error' });
});
// Coors Banquet Config
await server.register(cors, {
origin: (origin, cb) => {
const allowedOrigins = [
'http://localhost:5173', // DeFlock Legacy
'http://localhost:3000', // FlockHopper
'https://deflock.org',
'https://www.deflock.org',
'https://maps.deflock.org'
];
if (!origin || allowedOrigins.includes(origin) || /^https:\/\/.*\.deflock\.pages\.dev$/.test(origin)) {
cb(null, true);
} else {
cb(null, false);
}
},
methods: ['GET', 'HEAD', 'POST'],
});
server.addHook('onResponse', (request, reply, done) => {
const statusCode = reply.statusCode;
const route = (request.routeOptions as { url?: string })?.url ?? request.url.split('?')[0];
requestCounter.add(1, {
'http.route': route,
'http.request.method': request.method,
'http.response.status_code': statusCode,
...request.meta,
});
if (!request.errorHandled && statusCode >= 400 && statusCode !== 404) {
otelLogger.emit({
severityNumber: statusCode >= 500 ? SeverityNumber.ERROR : SeverityNumber.WARN,
severityText: statusCode >= 500 ? 'ERROR' : 'WARN',
body: `HTTP ${statusCode} ${request.method} ${route}`,
attributes: {
'error.type': classifyByStatus(statusCode),
'http.route': route,
'http.request.method': request.method,
'http.response.status_code': statusCode,
...request.meta,
},
});
}
done();
});
const requestCounter = meter.createCounter('http.server.requests.total', {
description: 'Total number of HTTP requests, by route, method, and status code',
});
const backgroundErrorCounter = meter.createCounter('background_job.errors.total', {
description: 'Total number of failures in fire-and-forget background jobs, by context',
});
const aiScreeningCounter = meter.createCounter('ai_screening.completed.total', {
description: 'Total number of contact submissions successfully screened by AI',
});
function logBackgroundError(context: string, error: unknown, attributes: Record<string, string> = {}) {
const message = error instanceof Error ? error.message : String(error);
// Always print locally, independent of whether the otelcol sidecar is up — background job
// failures otherwise have no visibility at all when the OTel pipeline isn't reachable
// (e.g. local dev without the collector running).
server.log.error({ context, ...attributes, error: message, stack: error instanceof Error ? error.stack : undefined }, 'Background job error');
otelLogger.emit({
severityNumber: SeverityNumber.ERROR,
severityText: 'ERROR',
body: message,
attributes: {
'error.type': classifyErrorMessage(message),
'background.context': context,
...attributes,
},
});
backgroundErrorCounter.add(1, { 'background.context': context, 'error.type': classifyErrorMessage(message) });
}
const nominatim = new NominatimClient();
const githubClient = new GithubClient();
const turnstileClient = new TurnstileClient();
const zammadClient = new ZammadClient();
const aiScreeningClient = new AiScreeningClient();
const shutdown = async () => {
server.log.info("Shutting down");
await server.close();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
server.get('/geocode', {
schema: {
querystring: {
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['query'],
},
response: {
200: NominatimResultSchema,
404: { type: 'object', properties: { error: { type: 'string' } } },
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { query } = request.query as { query: string };
reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400');
const result = await nominatim.geocodeSingleResult(query);
if (!result) {
return reply.status(404).send({ error: 'No results found' });
}
return result;
});
server.get('/geocode/multi', {
schema: {
querystring: {
type: 'object',
properties: {
query: { type: 'string' },
source: { type: 'string' },
},
required: ['query'],
},
response: {
200: {
type: 'array',
items: NominatimResultSchema,
},
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { query, source } = request.query as { query: string, source?: string };
const geoType = classifyGeoQuery(query);
request.meta = {
'geocode.source': geoType === 'zip_code' ? 'local_zip' : 'nominatim',
'geocode.initiator': source ?? '',
'geocode.query_type': geoType,
};
reply.header('Cache-Control', 'public, max-age=86400, s-maxage=86400');
const result = await nominatim.geocodePhrase(query, false);
return result;
});
server.get('/sponsors/github', {
schema: {
response: {
200: SponsorsResponseSchema,
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
reply.header('Cache-Control', 'public, max-age=60, s-maxage=600');
const result = await githubClient.getSponsors();
return result;
});
server.post('/contact/message', {
schema: {
body: ContactMessageBodySchema,
response: {
201: { type: 'object', properties: {} },
400: { type: 'object', properties: { error: { type: 'string' } } },
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { name, email, topic, subject, message, turnstileToken, aiScreeningOptOut } = request.body as ContactMessageBody;
const remoteIp = request.ip;
const valid = await turnstileClient.verify(turnstileToken, remoteIp);
if (!valid) {
return reply.status(400).send({ error: 'Invalid captcha' });
}
const ticket = await zammadClient.createTicket({ name, email, topic, subject, message });
if (aiScreeningOptOut) {
zammadClient.addTag(ticket.id, 'ai-opted-out')
.catch(err => logBackgroundError('ai_screening.opt_out_tag', err, { 'ticket.id': String(ticket.id) }));
} else {
const senderEmailDomain = email.split('@')[1] ?? '';
screenContactSubmission(
{ ticketId: ticket.id, topic, subject, message, senderEmailDomain, replyTo: email },
{ aiClient: aiScreeningClient, zammadClient },
)
.then(({ result }) => {
aiScreeningCounter.add(1, { 'contact.topic': topic, 'ai_category': result.ai_category });
})
.catch(err => {
logBackgroundError('ai_screening', err, { 'ticket.id': String(ticket.id) });
zammadClient.addTag(ticket.id, 'ai-screening-error')
.catch(tagErr => logBackgroundError('ai_screening.error_tag', tagErr, { 'ticket.id': String(ticket.id) }));
});
}
return reply.status(201).send({});
});
server.post('/contact/message/dry-run', {
schema: {
body: {
type: 'object',
required: ['topic', 'subject', 'message'],
properties: {
topic: {
type: 'string',
enum: ['website-support', 'app-support', 'local-groups', 'media', 'questions-comments'],
},
subject: { type: 'string', minLength: 1 },
message: { type: 'string', minLength: 1 },
email: { type: 'string' },
},
},
response: {
500: { type: 'object', properties: { error: { type: 'string' } } },
},
},
}, async (request, reply) => {
const { topic, subject, message, email } = request.body as {
topic: ContactMessageBody['topic'];
subject: string;
message: string;
email?: string;
};
const senderEmailDomain = email?.split('@')[1] ?? '';
const screening = await aiScreeningClient.screen({ topic, subject, message, senderEmailDomain });
const plannedActions = planZammadActions(screening);
return reply.status(200).send({ screening, plannedActions });
});
server.head('/healthcheck', async (request, reply) => {
reply.status(200).send();
});
try {
const defaultPort = process.env.NODE_ENV === 'development' ? 3420 : 3000;
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : defaultPort;
await server.listen({ host: '0.0.0.0', port });
console.log(`Server listening on port ${port}`);
} catch (err) {
console.error('Failed to start server:', err);
server.log.error(err);
process.exit(1);
}
};
start().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});