| import express from 'express' |
| import { omit, without, mapValues } from 'lodash-es' |
| import QuickLRU from 'quick-lru' |
| import { ErrorObject } from 'ajv' |
|
|
| import type { ExtendedRequest } from '@/types' |
| import type { Response } from 'express' |
|
|
| import { schemas, hydroNames } from './lib/schema' |
| import catchMiddlewareError from '@/observability/middleware/catch-middleware-error' |
| import { noCacheControl } from '@/frame/middleware/cache-control' |
| import { getJsonValidator } from '@/tests/lib/validate-json-schema' |
| import { formatErrors } from './lib/middleware-errors' |
| import { publish as _publish } from './lib/hydro' |
| import { analyzeComment, getGuessedLanguage } from './lib/analyze-comment' |
| import { EventType, EventProps, EventPropsByType } from './types' |
|
|
| const router = express.Router() |
| const OMIT_FIELDS = ['type'] |
| const allowedTypes = new Set(without(Object.keys(schemas), 'validation')) |
| const isProd = process.env.NODE_ENV === 'production' |
| const validators = mapValues(schemas, (schema) => getJsonValidator(schema)) |
|
|
| |
| |
| |
| |
| async function publish(...args: Parameters<typeof _publish>) { |
| if (isProd) { |
| _publish(...args) |
| return |
| } |
| return await _publish(...args) |
| } |
|
|
| const sentValidationErrors = new QuickLRU({ |
| maxSize: 10_000, |
| maxAge: 1000 * 60, |
| }) |
|
|
| |
| |
| const getValidationErrorHash = (validateErrors: ErrorObject[]) => { |
| |
| const window: number = Math.floor(new Date().getTime() / 10000) |
| return `${window}:${(validateErrors || []) |
| .map((error: ErrorObject) => error.message + error.instancePath + JSON.stringify(error.params)) |
| .join(':')}` |
| } |
|
|
| router.post( |
| '/', |
| catchMiddlewareError(async function postEvents(req: ExtendedRequest, res: Response) { |
| noCacheControl(res) |
|
|
| const eventsToProcess = Array.isArray(req.body) ? req.body : [req.body] |
| const validEvents: any[] = [] |
| const validationErrors: any[] = [] |
|
|
| for (const eventBody of eventsToProcess) { |
| try { |
| |
| if (!eventBody.type || !allowedTypes.has(eventBody.type)) { |
| continue |
| } |
| const type: EventType = eventBody.type |
| const body: EventProps & EventPropsByType[EventType] = eventBody |
| if (isSurvey(body) && body.survey_comment) { |
| body.survey_rating = await getSurveyCommentRating({ |
| comment: body.survey_comment, |
| language: body.context.path_language || 'en', |
| }) |
| body.survey_comment_language = await getGuessedLanguage(body.survey_comment) |
| } |
|
|
| if (body.context) { |
| |
| |
| body.context.dotcom_user = req.cookies?.dotcom_user ? req.cookies.dotcom_user : undefined |
| body.context.is_staff = Boolean(req.cookies?.staffonly) |
| |
| |
| body.context.ip = req.headers['fastly-client-ip'] as string | undefined |
| body.context.user_agent ??= req.headers['user-agent'] |
| } |
| const validate = validators[type] |
| if (!validate(body)) { |
| const hash = getValidationErrorHash(validate.errors || []) |
| if (!sentValidationErrors.has(hash)) { |
| sentValidationErrors.set(hash, true) |
| formatErrors(validate.errors || [], body).map((error) => { |
| validationErrors.push({ schema: hydroNames.validation, value: error }) |
| }) |
| } |
| continue |
| } |
| validEvents.push({ |
| schema: hydroNames[type], |
| value: omit(body, OMIT_FIELDS), |
| }) |
| } catch (eventError) { |
| console.error('Error validating event:', eventError) |
| } |
| } |
| if (validEvents.length > 0) { |
| await publish(validEvents) |
| } |
|
|
| if (validationErrors.length > 0) { |
| await publish(validationErrors) |
| } |
| const statusCode = validationErrors.length > 0 ? 400 : 200 |
|
|
| return res.status(statusCode).json( |
| isProd |
| ? undefined |
| : { |
| success_count: validEvents.length, |
| failure_count: validationErrors.length, |
| details: validationErrors, |
| }, |
| ) |
| }), |
| ) |
|
|
| |
| function isSurvey( |
| body: EventProps & EventPropsByType[EventType], |
| ): body is EventProps & EventPropsByType[EventType.survey] { |
| return body.type === EventType.survey |
| } |
|
|
| type GetSurveyCommentRatingArgs = { |
| comment: string |
| language: string |
| } |
| async function getSurveyCommentRating({ comment, language }: GetSurveyCommentRatingArgs) { |
| if (!comment || !comment.trim()) return |
| const { rating } = await analyzeComment(comment, language) |
| return rating |
| } |
|
|
| export default router |
|
|