Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions api-docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6179,6 +6179,112 @@
}
}
}
},
"/notification": {
"get": {
"tags": [
"Notification"
],
"summary": "Retrieves notifications for the authenticated user",
"description": "",
"operationId": "getNotifications",
"responses": {
"200": {
"description": "Notifications ordered newest first",
"content": {
"application/json": {
"schema": {
"$ref": "../schemas/notification/list-notifications-response.json"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/notification/{uuid}": {
"delete": {
"tags": [
"Notification"
],
"summary": "Dismisses one notification for the authenticated user",
"description": "",
"operationId": "dismissNotification",
"parameters": [
{
"name": "uuid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Notification dismissed"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Notification was not found for the authenticated user"
}
}
}
},
"/notification/target/{user_uuid}": {
"post": {
"tags": [
"Notification"
],
"summary": "Creates a notification for an existing user (Secretariat only)",
"description": "",
"operationId": "createNotificationForUser",
"parameters": [
{
"name": "user_uuid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Notification created",
"content": {
"application/json": {
"schema": {
"$ref": "../schemas/notification/notification.json"
}
}
}
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
},
"404": {
"description": "Not Found"
}
}
}
}
},
"components": {
Expand Down
6 changes: 6 additions & 0 deletions schemas/notification/list-notifications-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": { "notifications": { "type": "array", "items": { "$ref": "notification.json" } } },
"required": ["notifications"]
}
12 changes: 12 additions & 0 deletions schemas/notification/notification.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"UUID": { "type": "string" },
"body": { "type": "string" },
"links": { "type": "object", "properties": { "org_shortname": { "type": "string" }, "user_shortname": { "type": "string" } } },
"type": { "enum": ["PRIVATE_MESSAGE", "PUBLIC_MESSAGE"] },
"created_at": { "type": "string", "format": "date-time" }
},
"required": ["UUID", "body", "links", "type", "created_at"]
}
59 changes: 59 additions & 0 deletions src/controller/notification.controller/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const router = require('express').Router()
const { body, param } = require('express-validator')
const mw = require('../../middleware/middleware')
const controller = require('./notification.controller')

function parseError (req, res, next) {
const { validationResult } = require('express-validator')
const errors = validationResult(req)
if (!errors.isEmpty()) return res.status(400).json({ error: 'BAD_INPUT', message: 'Parameters were invalid', details: errors.array() })
next()
}

router.get('/notification',
/*
#swagger.tags = ['Notification']
#swagger.operationId = 'getNotifications'
#swagger.summary = 'Retrieves notifications for the authenticated user'
#swagger.responses[200] = { description: 'Notifications ordered newest first', content: { 'application/json': { schema: { $ref: '../schemas/notification/list-notifications-response.json' } } } }
*/
mw.validateUser,
Comment thread
david-rocca marked this conversation as resolved.
Dismissed
controller.getNotifications
)

router.delete('/notification/:uuid',
/*
#swagger.tags = ['Notification']
#swagger.operationId = 'dismissNotification'
#swagger.summary = 'Dismisses one notification for the authenticated user'
#swagger.responses[204] = { description: 'Notification dismissed' }
#swagger.responses[404] = { description: 'Notification was not found for the authenticated user' }
*/
mw.validateUser,
Comment thread
david-rocca marked this conversation as resolved.
Dismissed
param('uuid').isUUID(4),
parseError,
(req, res, next) => { req.ctx.params = { uuid: req.params.uuid }; next() },
controller.dismissNotification
)

router.post('/notification/target/:user_uuid',
/*
#swagger.tags = ['Notification']
#swagger.operationId = 'createNotificationForUser'
#swagger.summary = 'Creates a notification for an existing user (Secretariat only)'
#swagger.responses[201] = { description: 'Notification created', content: { 'application/json': { schema: { $ref: '../schemas/notification/notification.json' } } } }
*/
mw.validateUser,
Comment thread
david-rocca marked this conversation as resolved.
Dismissed
mw.onlySecretariat,
param('user_uuid').isUUID(4),
body('body').isString().trim().notEmpty(),
body('type').isIn(['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE']),
body('links').optional().isObject(),
body('links.org_shortname').optional().isString(),
body('links.user_shortname').optional().isString(),
parseError,
(req, res, next) => { req.ctx.params = { user_uuid: req.params.user_uuid }; req.ctx.body = req.body; next() },
controller.createNotificationForUser
)

module.exports = router
45 changes: 45 additions & 0 deletions src/controller/notification.controller/notification.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const authContext = require('../../utils/authContext')

async function getNotifications (req, res, next) {
try {
const notificationRepo = req.ctx.repositories.getNotificationRepository()
const userRepo = req.ctx.repositories.getBaseUserRepository()
const orgRepo = req.ctx.repositories.getBaseOrgRepository()
const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo)
return res.status(200).json({ notifications: await notificationRepo.getNotifications(userUUID) })
} catch (err) {
next(err)
}
}

async function dismissNotification (req, res, next) {
try {
const notificationRepo = req.ctx.repositories.getNotificationRepository()
const userRepo = req.ctx.repositories.getBaseUserRepository()
const orgRepo = req.ctx.repositories.getBaseOrgRepository()
const userUUID = await authContext.getRequesterUserUUID(req, userRepo, orgRepo)
const dismissed = await notificationRepo.dismissNotification(userUUID, req.ctx.params.uuid)
if (!dismissed) return res.status(404).json({ error: 'NOTIFICATION_DNE', message: 'Notification does not exist.' })
return res.status(204).send()
} catch (err) {
next(err)
}
}

async function createNotificationForUser (req, res, next) {
try {
const notificationRepo = req.ctx.repositories.getNotificationRepository()
const userRepo = req.ctx.repositories.getBaseUserRepository()
const recipient = await userRepo.findUserByUUID(req.ctx.params.user_uuid)
if (!recipient) return res.status(404).json({ error: 'USER_DNE', message: 'User does not exist.' })
if (!notificationRepo.validateNotification(req.ctx.body)) {
return res.status(400).json({ error: 'BAD_INPUT', message: 'Notification body, links, or type is invalid.' })
}
const notification = await notificationRepo.addNotification(recipient.UUID, req.ctx.body)
return res.status(201).json(notification)
} catch (err) {
next(err)
}
}

module.exports = { getNotifications, dismissNotification, createNotificationForUser }
21 changes: 21 additions & 0 deletions src/model/notification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const mongoose = require('mongoose')

const NotificationSchema = new mongoose.Schema({
user_uuid: { type: String, required: true, unique: true, index: true },
notifications: [{
_id: false,
UUID: { type: String, required: true },
body: { type: String, required: true },
links: {
_id: false,
org_shortname: { type: String, default: '' },
user_shortname: { type: String, default: '' }
},
type: { type: String, enum: ['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'], required: true },
created_at: { type: Date, required: true }
}]
}, { collection: 'Notification' })

NotificationSchema.index({ user_uuid: 1, 'notifications.created_at': -1 })

module.exports = mongoose.model('Notification', NotificationSchema)
10 changes: 9 additions & 1 deletion src/repositories/baseOrgRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,6 @@ function normalizeProgramDataStatus (org) {
const normalizedStatus = PROGRAM_DATA_STATUS_BY_LOWERCASE.get(status.toLowerCase())
if (normalizedStatus) org.program_data.status = normalizedStatus
}

function isResponseExtensionField (key) {
return key.startsWith('_') && !INTERNAL_UNDERSCORE_FIELDS.includes(key)
}
Expand Down Expand Up @@ -512,6 +511,15 @@ class BaseOrgRepository extends BaseRepository {
).lean()
}

async findSecretariatUserUUIDs (options = {}) {
const orgs = await BaseOrgModel.find(
{ authority: 'SECRETARIAT' },
{ _id: 0, users: 1 },
options
).lean()
return [...new Set(orgs.flatMap(org => Array.isArray(org.users) ? org.users : []))]
}

/**
* @function hasRole
* @description Checks if an organization object has the requested role.
Expand Down
5 changes: 5 additions & 0 deletions src/repositories/baseUserRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ class BaseUserRepository extends BaseRepository {
).lean()
}

async findActiveUsersByUUIDs (uuids, options = {}) {
if (!Array.isArray(uuids) || uuids.length === 0) return []
return await BaseUser.find({ UUID: { $in: uuids }, status: 'active' }, { _id: 0, UUID: 1 }, options).lean()
}

/**
* @async
* @function isUserAdminOfOrgUUID
Expand Down
23 changes: 23 additions & 0 deletions src/repositories/conversationRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,29 @@ class ConversationRepository extends BaseRepository {
const newConversation = new ConversationModel(conversationObj)
const result = await newConversation.save(options)

const BaseOrgRepository = require('./baseOrgRepository')
const BaseUserRepository = require('./baseUserRepository')
const NotificationRepository = require('./notificationRepository')
const orgRepo = new BaseOrgRepository()
const userRepo = new BaseUserRepository()
const notificationRepo = new NotificationRepository()
const targetOrg = await orgRepo.findOneByUUID(targetUUID, options, false, { UUID: 1, short_name: 1, users: 1 })
const targetUser = targetOrg ? null : await userRepo.findUserByUUID(targetUUID, options)
let recipientUUIDs = await orgRepo.findSecretariatUserUUIDs(options)
if (result.visibility === 'public' && isSecretariat) {
const targetRecipientUUIDs = targetOrg?.users || (targetUser ? [targetUser.UUID] : [])
recipientUUIDs = [...recipientUUIDs, ...targetRecipientUUIDs]
}
if (isSecretariat) {
recipientUUIDs = recipientUUIDs.filter(recipientUUID => recipientUUID !== user.UUID)
}
const activeRecipients = await userRepo.findActiveUsersByUUIDs(recipientUUIDs, options)
await notificationRepo.addNotifications(activeRecipients.map(recipient => recipient.UUID), {
body: result.body,
links: { org_shortname: targetOrg?.short_name || '', user_shortname: user.username || '' },
type: result.visibility === 'private' ? 'PRIVATE_MESSAGE' : 'PUBLIC_MESSAGE'
}, options)

const rawObject = result.toObject()

delete rawObject._id
Expand Down
61 changes: 61 additions & 0 deletions src/repositories/notificationRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const uuid = require('uuid')
const NotificationModel = require('../model/notification')
const BaseRepository = require('./baseRepository')

class NotificationRepository extends BaseRepository {
constructor () {
super(NotificationModel)
}

validateNotification (notification) {
return notification &&
typeof notification.body === 'string' && notification.body.trim().length > 0 &&
['PRIVATE_MESSAGE', 'PUBLIC_MESSAGE'].includes(notification.type) &&
(!notification.links || (typeof notification.links === 'object' && !Array.isArray(notification.links) &&
(!notification.links.org_shortname || typeof notification.links.org_shortname === 'string') &&
(!notification.links.user_shortname || typeof notification.links.user_shortname === 'string')))
}

createNotification (notification) {
return {
UUID: uuid.v4(),
body: notification.body,
links: {
org_shortname: notification.links?.org_shortname || '',
user_shortname: notification.links?.user_shortname || ''
},
type: notification.type,
created_at: new Date()
}
}

async addNotification (userUUID, notification, options = {}) {
const createdNotification = this.createNotification(notification)
await NotificationModel.updateOne(
{ user_uuid: userUUID },
{ $push: { notifications: createdNotification } },
{ upsert: true, ...options }
)
return createdNotification
}

async addNotifications (userUUIDs, notification, options = {}) {
return Promise.all([...new Set(userUUIDs)].map(userUUID => this.addNotification(userUUID, notification, options)))
}

async getNotifications (userUUID, options = {}) {
const result = await NotificationModel.findOne({ user_uuid: userUUID }, null, options).lean()
return (result?.notifications || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
}

async dismissNotification (userUUID, notificationUUID, options = {}) {
const result = await NotificationModel.updateOne(
{ user_uuid: userUUID, 'notifications.UUID': notificationUUID },
{ $pull: { notifications: { UUID: notificationUUID } } },
options
)
return result.modifiedCount === 1
}
}

module.exports = NotificationRepository
Loading
Loading