diff --git a/api/doc/events/post-req/schema.js b/api/doc/events/post-req/schema.js index d857f7c..fd95a1e 100644 --- a/api/doc/events/post-req/schema.js +++ b/api/doc/events/post-req/schema.js @@ -1,3 +1,5 @@ +import PostSingleReqSchema from '../post-single-req/schema.js' + export default { $id: 'https://github.com/data-fair/events/events/post-req', title: 'Post event req', @@ -7,9 +9,8 @@ export default { properties: { body: { type: 'array', - items: { - $ref: 'https://github.com/data-fair/lib/event' - } + items: PostSingleReqSchema.properties.body } - } + }, + $defs: PostSingleReqSchema.$defs } diff --git a/api/doc/events/post-single-req/schema.js b/api/doc/events/post-single-req/schema.js index f439aa0..4bd812b 100644 --- a/api/doc/events/post-single-req/schema.js +++ b/api/doc/events/post-single-req/schema.js @@ -1,3 +1,6 @@ +import jsonSchema from '@data-fair/lib-utils/json-schema.js' +import EventSchema from '@data-fair/lib-common-types/event/schema.js' + export default { $id: 'https://github.com/data-fair/events/events/post-single-req', title: 'Post single event req', @@ -5,6 +8,12 @@ export default { type: 'object', required: ['body'], properties: { - body: { $ref: 'https://github.com/data-fair/lib/event' } - } + body: jsonSchema(EventSchema) + .removeReadonlyProperties() + .removeFromRequired(['date']) + .removeId() + .appendTitle(' post') + .schema + }, + $defs: EventSchema.$defs } diff --git a/api/doc/notifications/post-req/schema.js b/api/doc/notifications/post-req/schema.js index d877c22..50a5cdf 100644 --- a/api/doc/notifications/post-req/schema.js +++ b/api/doc/notifications/post-req/schema.js @@ -1,5 +1,5 @@ import jsonSchema from '@data-fair/lib-utils/json-schema.js' -import NotificationSchema from '#types/notification/schema.js' +import NotificationSchema from '@data-fair/lib-common-types/notification/schema.js' export default { $id: 'https://github.com/data-fair/events/notifications/post-req', @@ -11,7 +11,7 @@ export default { body: jsonSchema(NotificationSchema) .removeReadonlyProperties() - .removeFromRequired(['visibility']) + .removeFromRequired(['date']) .removeId() .appendTitle(' post') .schema diff --git a/api/package.json b/api/package.json index 6b2c397..644c5cb 100644 --- a/api/package.json +++ b/api/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@types/express": "^5.0.6", + "@types/ws": "^8.18.1", "@types/fs-extra": "^11.0.4", "@types/i18n": "^0.13.12", "@types/node-pushnotifications": "^3.1.1", diff --git a/api/src/events/router.ts b/api/src/events/router.ts index 4a89a89..0619792 100644 --- a/api/src/events/router.ts +++ b/api/src/events/router.ts @@ -1,5 +1,5 @@ import type { Filter, Sort } from 'mongodb' -import type { FullEvent } from '#types' +import type { Event, FullEvent } from '#types' import { Router } from 'express' import mongo from '#mongo' @@ -55,8 +55,12 @@ router.get('', async (req, res, next) => { router.post('', async (req, res, next) => { assertReqInternalSecret(req, config.secretKeys.events) const { body } = postReq.returnValid(req, { name: 'req' }) + const events: Event[] = body.map(e => ({ + date: new Date().toISOString(), + ...e, + })) - await postEvents(body) + await postEvents(events) res.status(201).json(body) }) diff --git a/api/src/events/service.ts b/api/src/events/service.ts index 216962a..58eb7cc 100644 --- a/api/src/events/service.ts +++ b/api/src/events/service.ts @@ -1,4 +1,4 @@ -import type { Filter } from 'mongodb' +import { MongoBulkWriteError, type Filter } from 'mongodb' import type { Event, FullEvent, SearchableEvent, LocalizedEvent, Subscription, WebhookSubscription, Notification } from '#types' import config from '#config' import mongo from '#mongo' @@ -61,10 +61,10 @@ export const postEvents = async (events: Event[]) => { // this logic should work much better on a mongodb version that would support multi-language indexing // https://www.mongodb.com/docs/manual/core/indexes/index-types/index-text/specify-language-text-index/create-text-index-multiple-languages/ const event: SearchableEvent = { - ...rawEvent, _id: nanoid(), - _search: [], - visibility: rawEvent.visibility ?? 'private' + visibility: 'private', + ...rawEvent, + _search: [] } for (const locale of config.i18n.locales) { const localizedEvent = localizeEvent(event, locale) @@ -98,11 +98,43 @@ export const postEvents = async (events: Event[]) => { await createWebhook(localizeEvent(event), webhookSubscription) } } - if (eventsBulkOp.length) await eventsBulkOp.execute() - if (notifsBulkOp.length) await notifsBulkOp.execute() + + if (eventsBulkOp.length) { + try { + await eventsBulkOp.execute({}) + } catch (err) { + // we ignore conflict error, meaning that the same event id can be sent twice without triggering a failure + // but without being inserted twice either + if (err instanceof MongoBulkWriteError) { + const nonConflictError = err.result.getWriteErrors().find(e => e.code !== 11000) + if (nonConflictError) throw err + } else { + throw err + } + } + } + + const insertedNotifications: Notification[] = [] + if (notifsBulkOp.length) { + try { + const res = await notifsBulkOp.execute({}) + for (const id of Object.values(res.insertedIds)) { + insertedNotifications.push(notifications.find(n => n._id === id)!) + } + } catch (err) { + // we ignore conflict error, meaning that the same event id can be sent twice without triggering a failure + // but without being inserted twice either + if (err instanceof MongoBulkWriteError) { + const nonConflictError = err.result.getWriteErrors().find(e => e.code !== 11000) + if (nonConflictError) throw err + } else { + throw err + } + } + } // insertion must be performed before, so that data is available on receiving a WS event - for (const notification of notifications) { + for (const notification of insertedNotifications) { await sendNotification(notification, true) } } diff --git a/api/src/mongo.ts b/api/src/mongo.ts index 8a93b3c..d2522cf 100644 --- a/api/src/mongo.ts +++ b/api/src/mongo.ts @@ -61,7 +61,14 @@ export class EventsMongo { ] }, notifications: { - 'main-keys': { 'recipient.id': 1, date: -1 } + 'main-keys': { 'recipient.id': 1, date: -1 }, + 'unique-event': [ + { 'recipient.id': 1, eventId: 1 }, + { + unique: true, + partialFilterExpression: { eventId: { $exists: true } } + } + ] }, 'webhook-subscriptions': { 'main-keys': { 'sender.type': 1, 'sender.id': 1, 'owner.type': 1, 'owner.id': 1, 'topic.key': 1 } diff --git a/api/src/notifications/router.ts b/api/src/notifications/router.ts index 81158ea..e9fd0ab 100644 --- a/api/src/notifications/router.ts +++ b/api/src/notifications/router.ts @@ -1,7 +1,6 @@ import type { SortDirection } from 'mongodb' import type { Pointer } from '../types.ts' import type { Notification } from '#types' -import { nanoid } from 'nanoid' import { Router } from 'express' import debugModule from 'debug' import mongo from '#mongo' @@ -12,6 +11,7 @@ import * as eventsPostSingleReq from '#doc/events/post-single-req/index.ts' import * as notificationsPostReq from '#doc/notifications/post-req/index.ts' import { postEvents } from '../events/service.ts' import { sendNotification } from './service.ts' +import { nanoid } from 'nanoid' const debug = debugModule('events') @@ -66,7 +66,7 @@ router.post('', async (req, res, next) => { delete req.body.recipient } const { body } = eventsPostSingleReq.returnValid(req, { name: 'req' }) - await postEvents([body]) + await postEvents([{ date: new Date().toISOString(), ...body }]) res.status(201).json(body) } else { debug('pushing a notification with a recipient', req.body) @@ -81,10 +81,11 @@ router.post('', async (req, res, next) => { const { body } = notificationsPostReq.returnValid(req, { name: 'req' }) const notification: Notification = { - ...body, _id: nanoid(), - date: new Date().toISOString() + date: new Date().toISOString(), + ...body, } + await sendNotification(notification) res.status(200).json(notification) } diff --git a/api/src/notifications/service.ts b/api/src/notifications/service.ts index 10b6cc6..7b34294 100644 --- a/api/src/notifications/service.ts +++ b/api/src/notifications/service.ts @@ -13,6 +13,7 @@ import config from '#config' import * as metrics from './metrics.js' import { localizeEvent } from '../events/service.ts' import * as pushService from '../push/service.ts' +import { MongoError } from 'mongodb' const debug = Debug('notifications') @@ -24,6 +25,7 @@ export const prepareSubscriptionNotification = (event: FullEvent, subscription: delete localizedEvent.originator delete localizedEvent.urlParams const notification: Notification = { + eventId: event._id, icon: subscription.icon || config.theme.notificationIcon || (subscription.origin + '/events/logo-192x192.png'), locale: subscription.locale, ...localizedEvent, @@ -54,7 +56,18 @@ export const prepareSubscriptionNotification = (event: FullEvent, subscription: export const sendNotification = async (notification: Notification, skipInsert = false) => { // global.events.emit('saveNotification', notification) - if (!skipInsert) await mongo.notifications.insertOne(notification) + if (!skipInsert) { + try { + await mongo.notifications.insertOne(notification) + } catch (err) { + if (err instanceof MongoError && err.code === 11000) { + // conflict error, simply ignore this duplicate notification + return + } else { + throw err + } + } + } debug('Send WS notif', notification.recipient, notification) await wsEmitter.emit(`user:${notification.recipient.id}:notifications`, notification) if (notification.outputs && notification.outputs.includes('devices')) { diff --git a/api/types/index.ts b/api/types/index.ts index eafd0a6..36996c2 100644 --- a/api/types/index.ts +++ b/api/types/index.ts @@ -1,13 +1,13 @@ import type { Event } from '@data-fair/lib-common-types/event/index.js' +import type { Notification } from '@data-fair/lib-common-types/notification/index.js' +export type { Event, Notification } export type { Subscription } from './subscription/index.js' -export type { Notification } from './notification/index.js' export type { Webhook } from './webhook/index.js' export type { WebhookSubscription } from './webhook-subscription/index.js' -export type { Event } export type { DevicesPushSubscription } from './push-subscription/index.js' export type { DeviceRegistration } from './device-registration/index.js' -export type FullEvent = Event & Required> & { _id: string } +export type FullEvent = Event & Required> export type LocalizedEvent = Omit & { title: string, body?: string, htmlBody?: string } export type SearchableEvent = FullEvent & { _search: { language: string, text: string }[] } diff --git a/api/types/notification/index.js b/api/types/notification/index.js deleted file mode 100644 index f5d615b..0000000 --- a/api/types/notification/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './.type/index.js' diff --git a/api/types/notification/schema.js b/api/types/notification/schema.js deleted file mode 100644 index 8da0eae..0000000 --- a/api/types/notification/schema.js +++ /dev/null @@ -1,78 +0,0 @@ -export default { - $id: 'https://github.com/data-fair/events/notification', - 'x-exports': ['types', 'validate'], - title: 'Notification', - type: 'object', - additionalProperties: false, - required: ['_id', 'title', 'topic', 'recipient', 'date'], - properties: { - _id: { - type: 'string', - title: 'Identifiant', - readOnly: true - }, - origin: { - type: 'string', - title: 'Site d\'origine de la souscription', - readOnly: true - }, - title: { - type: 'string', - title: 'Titre' - }, - body: { - type: 'string', - title: 'Contenu' - }, - htmlBody: { - type: 'string', - title: 'Contenu HTML' - }, - locale: { - type: 'string', - title: 'Langue de la notification', - enum: ['fr', 'en'] - }, - icon: { - type: 'string', - title: 'URL de l\'icone de la notification' - }, - // sender is the owner of the topic - sender: { $ref: 'https://github.com/data-fair/lib/event#/$defs/sender' }, - topic: { $ref: 'https://github.com/data-fair/lib/event#/$defs/topicRef' }, - // the recipient of the matched subscription - recipient: { $ref: 'https://github.com/data-fair/events/partial#/$defs/recipient' }, - outputs: { - type: 'array', - title: 'Sorties', - items: { - type: 'string', - oneOf: [{ - const: 'devices', - title: 'recevoir la notification sur vos appareils configurés' - }, { - const: 'email', - title: 'recevoir la notification par email' - }] - } - }, - url: { - type: 'string', - title: 'défini explicitement ou calculé à partir de subscription.urlTemplate et event.urlParams', - }, - date: { - readOnly: true, - type: 'string', - description: 'reception date', - format: 'date-time' - }, - new: { - readOnly: true, - type: 'boolean' - }, - extra: { - type: 'object', - description: 'propriétés libres qui varient en fonction du type de notification' - } - } -} diff --git a/package-lock.json b/package-lock.json index ef350e7..1609496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,8 @@ "@types/i18n": "^0.13.12", "@types/node-pushnotifications": "^3.1.1", "@types/useragent": "^2.3.4", - "@types/web-push": "^3.6.4" + "@types/web-push": "^3.6.4", + "@types/ws": "^8.18.1" } }, "api/node_modules/nanoid": { @@ -1792,16 +1793,6 @@ "url": "https://github.com/sponsors/kazupon" } }, - "node_modules/@isaacs/cliui": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", - "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3025,12 +3016,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/node-pushnotifications": { @@ -3175,6 +3166,16 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", @@ -4075,9 +4076,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4582,9 +4583,9 @@ "optional": true }, "node_modules/bn.js": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", - "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", "license": "MIT" }, "node_modules/body-parser": { @@ -6097,14 +6098,11 @@ } }, "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", - "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", "dev": true, "license": "MIT", - "dependencies": { - "jackspeak": "^4.2.3" - }, "engines": { "node": "20 || >=22" } @@ -6123,16 +6121,16 @@ } }, "node_modules/eslint-plugin-import-x/node_modules/minimatch": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", - "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8426,22 +8424,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", - "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -10071,9 +10053,9 @@ } }, "node_modules/node-pushnotifications/node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", "funding": [ { "type": "github", @@ -10792,6 +10774,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", "dependencies": { "detect-libc": "^2.0.0", @@ -13135,17 +13118,17 @@ } }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT", "optional": true }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -13968,9 +13951,9 @@ } }, "node_modules/vuetify": { - "version": "3.11.8", - "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.8.tgz", - "integrity": "sha512-4iKnntOnLFFklygZjzlVfcHrtLO8+iK4HOhiia6HP2U8v82x+ngaSCgm+epvPrGyCMfCpfuEttqD2qElrr1axw==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.0.tgz", + "integrity": "sha512-N1y3sxLAyrblBHJ6vFTQoTM9icwZd/jNUsmYVQTvHNQHN22XDqb0w2+ujaSoEn/JCHbtGb70tKRlB9SJ6HhVgg==", "license": "MIT", "funding": { "type": "github", diff --git a/test-it/01-events.ts b/test-it/01-events.ts index 6a75a34..350fee7 100644 --- a/test-it/01-events.ts +++ b/test-it/01-events.ts @@ -78,4 +78,24 @@ describe('events', () => { assert.equal(res.data.results.length, 1) assert.equal(res.data.results[0].title, 'an english notification') }) + + it('should send an event with same id twice', async () => { + let res = await axPush.post('/api/events', [{ + _id: 'test', + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'notif 1', + sender: { type: 'user', id: 'user1', name: 'User 1' } + }]) + res = await axPush.post('/api/events', [{ + _id: 'test', + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'notif 2', + sender: { type: 'user', id: 'user1', name: 'User 1' } + }]) + res = await user1.get('/api/events') + assert.equal(res.data.results.length, 1) + assert.equal(res.data.results[0].title, 'notif 1') + }) }) diff --git a/test-it/02-subscriptions.ts b/test-it/02-subscriptions.ts index 09eca35..da28cad 100644 --- a/test-it/02-subscriptions.ts +++ b/test-it/02-subscriptions.ts @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert' import { it, describe, before, beforeEach, after } from 'node:test' +import WebSocket from 'ws' import { axios, axiosAuth, clean, startApiServer, stopApiServer } from './utils/index.ts' const axPush = axios({ params: { key: 'SECRET_EVENTS' }, baseURL: 'http://localhost:8082/events' }) @@ -221,4 +222,126 @@ describe('subscriptions', () => { res = await user2.get('/api/notifications') assert.equal(res.data.count, 1) }) + + it('should send notificationss of de-duplicated events', async () => { + // user1 is subscribed in 2 different manners + let res = await user1.post('/api/subscriptions', { + topic: { key: 'topic1' }, + sender: { type: 'organization', id: 'orga1' } + }) + res = await user1.post('/api/subscriptions', { + topic: { key: 'topic2' }, + sender: { type: 'organization', id: 'orga1' } + }) + + // admin is subscribed in the second manner only + res = await admin1.post('/api/subscriptions', { + topic: { key: 'topic2' }, + sender: { type: 'organization', id: 'orga1' } + }) + + res = await axPush.post('/api/events', [{ + _id: 'test', + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'notif 1', + sender: { type: 'organization', id: 'orga1', name: 'Orga 1' } + }]) + res = await axPush.post('/api/events', [{ + _id: 'test', + date: new Date().toISOString(), + topic: { key: 'topic2' }, + title: 'notif 2', + sender: { type: 'organization', id: 'orga1', name: 'Orga 1' } + }]) + res = await admin1.get('/api/notifications') + assert.equal(res.data.count, 1) + // no duplicate created + res = await user1.get('/api/notifications') + assert.equal(res.data.count, 1) + + // another notification sent straight to the user + res = await axPush.post('/api/notifications', { + // eventId: 'test', + date: new Date().toISOString(), + topic: { key: 'topic2' }, + title: 'notif 2', + recipient: { id: 'user1' } + }) + res = await user1.get('/api/notifications') + assert.equal(res.data.count, 2) + + // a duplicate not saved + res = await axPush.post('/api/notifications', { + eventId: 'test', + date: new Date().toISOString(), + topic: { key: 'topic2' }, + title: 'notif 2', + recipient: { id: 'user1' } + }) + res = await user1.get('/api/notifications') + assert.equal(res.data.count, 2) + }) + + it('should deliver direct notifications via WS', async () => { + const cookies = user1.cookieJar.getCookiesSync('http://localhost:5600') + const ws = new WebSocket('ws://localhost:8082', { headers: { Cookie: cookies.map(String).join('; ') } }) + const messages: any[] = [] + + await new Promise((resolve, reject) => { + ws.on('message', (raw: Buffer) => { + const msg = JSON.parse(raw.toString()) + if (msg.type === 'subscribe-confirm') resolve() + if (msg.type === 'message') messages.push(msg) + }) + ws.on('open', () => ws.send(JSON.stringify({ type: 'subscribe', channel: 'user:user1:notifications' }))) + ws.on('error', reject) + }) + + await axPush.post('/api/notifications', { + topic: { key: 'topic1' }, + sender: { type: 'user', id: 'user1', name: 'User 1' }, + title: 'notif direct 1', + recipient: { id: 'user1' } + }) + await new Promise(resolve => setTimeout(resolve, 200)) + assert.equal(messages.length, 1) + assert.equal(messages[0].data.title, 'notif direct 1') + + await axPush.post('/api/notifications', { + topic: { key: 'topic1' }, + sender: { type: 'user', id: 'user1', name: 'User 1' }, + title: 'notif direct 2', + recipient: { id: 'user1' } + }) + await new Promise(resolve => setTimeout(resolve, 200)) + assert.equal(messages.length, 2) + assert.equal(messages[1].data.title, 'notif direct 2') + + // a duplicate should not produce a WS message + await axPush.post('/api/notifications', { + eventId: 'ws-dedup', + topic: { key: 'topic1' }, + sender: { type: 'user', id: 'user1', name: 'User 1' }, + title: 'notif dedup', + recipient: { id: 'user1' } + }) + await new Promise(resolve => setTimeout(resolve, 200)) + assert.equal(messages.length, 3) + + await axPush.post('/api/notifications', { + eventId: 'ws-dedup', + topic: { key: 'topic1' }, + sender: { type: 'user', id: 'user1', name: 'User 1' }, + title: 'notif dedup', + recipient: { id: 'user1' } + }) + await new Promise(resolve => setTimeout(resolve, 200)) + assert.equal(messages.length, 3) + + const res = await user1.get('/api/notifications') + assert.equal(res.data.count, 3) + + ws.close() + }) })