Files
overleaf-cep/services/notifications/app/js/NotificationsController.ts
T
Jakob Ackermann f48132a70e [notifications] adopt strict zod types and extend test coverage (#36049)
* [notifications] adopt strict zod types and extend test coverage

* [notifications] validate expires date via schema

GitOrigin-RevId: 25dee0578ec443447e4c38a50a7e6c8b44831fe7
2026-09-17 11:45:58 +00:00

242 lines
7.5 KiB
TypeScript

import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import Notifications from './Notifications.js'
import { expressify } from '@overleaf/promise-utils'
import { parseReq, z, zz } from '@overleaf/validation-tools'
import type { Request, Response } from 'express'
const getUserNotificationsSchema = z.object({
params: z.strictObject({
user_id: zz.objectId(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const getUserNotificationsFallbackSchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
})
async function getUserNotifications(req: Request, res: Response) {
const { params } = parseReq(req, getUserNotificationsSchema, {
fallbackSchema: getUserNotificationsFallbackSchema,
})
logger.debug({ userId: params.user_id }, 'getting user unread notifications')
metrics.inc('getUserNotifications')
const notifications = await Notifications.getUserNotifications(params.user_id)
res.json(notifications)
}
const addNotificationSchema = z.object({
params: z.strictObject({
user_id: zz.objectId(),
}),
body: z.strictObject({
key: z.string(),
templateKey: z.string(),
// Arbitrary per-template render params: different notification types
// populate completely different keys here (see web's
// NotificationsBuilder.mjs, the only real production caller), so this is
// a genuinely open map rather than an approximated shape.
messageOpts: z.record(z.string(), z.unknown()).optional(),
// web's NotificationsHandler.createNotification always sends this (it
// defaults to `true` client-side before serializing), but
// Notifications.js already tolerates a missing value as falsy (skip
// overwriting an existing notification), so keep it optional here too.
forceCreate: z.boolean().optional(),
expires: z.iso.datetime().optional(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const addNotificationFallbackSchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
body: z.looseObject({}),
})
async function addNotification(req: Request, res: Response) {
const { params, body } = parseReq(req, addNotificationSchema, {
fallbackSchema: addNotificationFallbackSchema,
})
logger.debug(
{ userId: params.user_id, notification: body },
'adding notification'
)
metrics.inc('addNotification')
try {
await Notifications.addNotification(params.user_id, body)
res.sendStatus(200)
} catch (err) {
res.sendStatus(500)
}
}
const removeNotificationIdSchema = z.object({
params: z.strictObject({
user_id: zz.objectId(),
notification_id: zz.objectId(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const removeNotificationIdFallbackSchema = z.object({
params: z.object({
user_id: zz.objectId(),
notification_id: zz.objectId(),
}),
})
async function removeNotificationId(req: Request, res: Response) {
const { params } = parseReq(req, removeNotificationIdSchema, {
fallbackSchema: removeNotificationIdFallbackSchema,
})
logger.debug(
{
userId: params.user_id,
notificationId: params.notification_id,
},
'mark id notification as read'
)
metrics.inc('removeNotificationId')
await Notifications.removeNotificationId(
params.user_id,
params.notification_id
)
res.sendStatus(200)
}
const removeNotificationKeySchema = z.object({
params: z.strictObject({
user_id: zz.objectId(),
}),
body: z.strictObject({
key: z.string(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const removeNotificationKeyFallbackSchema = z.object({
params: z.object({
user_id: zz.objectId(),
}),
body: z.object({
key: z.string(),
}),
})
async function removeNotificationKey(req: Request, res: Response) {
const { params, body } = parseReq(req, removeNotificationKeySchema, {
fallbackSchema: removeNotificationKeyFallbackSchema,
})
logger.debug(
{ userId: params.user_id, notificationKey: body.key },
'mark key notification as read'
)
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationKey(params.user_id, body.key)
res.sendStatus(200)
}
const removeNotificationByKeyOnlySchema = z.object({
params: z.strictObject({
key: z.string(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const removeNotificationByKeyOnlyFallbackSchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function removeNotificationByKeyOnly(req: Request, res: Response) {
const { params } = parseReq(req, removeNotificationByKeyOnlySchema, {
fallbackSchema: removeNotificationByKeyOnlyFallbackSchema,
})
const notificationKey = params.key
logger.debug({ notificationKey }, 'mark notification as read by key only')
metrics.inc('removeNotificationKey')
await Notifications.removeNotificationByKeyOnly(notificationKey)
res.sendStatus(200)
}
const countNotificationsByKeyOnlySchema = z.object({
params: z.strictObject({
key: z.string(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const countNotificationsByKeyOnlyFallbackSchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function countNotificationsByKeyOnly(req: Request, res: Response) {
const { params } = parseReq(req, countNotificationsByKeyOnlySchema, {
fallbackSchema: countNotificationsByKeyOnlyFallbackSchema,
})
const notificationKey = params.key
try {
const count =
await Notifications.countNotificationsByKeyOnly(notificationKey)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot count by key')
res.sendStatus(500)
}
}
const deleteUnreadNotificationsByKeyOnlyBulkSchema = z.object({
params: z.strictObject({
key: z.string(),
}),
})
// Rollout-temporary fallback (pre-refinement schema from main); delete
// when this route's REQ_VALIDATION_MODE instrumentation is removed.
const deleteUnreadNotificationsByKeyOnlyBulkFallbackSchema = z.object({
params: z.object({
key: z.string(),
}),
})
async function deleteUnreadNotificationsByKeyOnlyBulk(
req: Request,
res: Response
) {
const { params } = parseReq(
req,
deleteUnreadNotificationsByKeyOnlyBulkSchema,
{ fallbackSchema: deleteUnreadNotificationsByKeyOnlyBulkFallbackSchema }
)
const notificationKey = params.key
try {
const count =
await Notifications.deleteUnreadNotificationsByKeyOnlyBulk(
notificationKey
)
res.json({ count })
} catch (err) {
logger.err({ err, notificationKey }, 'cannot bulk remove by key')
res.sendStatus(500)
}
}
export default {
getUserNotifications: expressify(getUserNotifications),
addNotification: expressify(addNotification),
deleteUnreadNotificationsByKeyOnlyBulk: expressify(
deleteUnreadNotificationsByKeyOnlyBulk
),
removeNotificationByKeyOnly: expressify(removeNotificationByKeyOnly),
removeNotificationId: expressify(removeNotificationId),
removeNotificationKey: expressify(removeNotificationKey),
countNotificationsByKeyOnly: expressify(countNotificationsByKeyOnly),
}