diff --git a/api/controllers/v1/notification/mark-as-read-batch.js b/api/controllers/v1/notification/mark-as-read-batch.js new file mode 100644 index 000000000..2ae002d06 --- /dev/null +++ b/api/controllers/v1/notification/mark-as-read-batch.js @@ -0,0 +1,49 @@ +const MAX_IDS = 1000; + +module.exports = async (req, res) => { + const rawIds = req.body && Array.isArray(req.body.ids) ? req.body.ids : []; + + if (rawIds.length > 0) { + // Validate that all provided IDs are positive integers + const allValid = rawIds.every((id) => Number.isInteger(id) && id > 0); + if (!allValid) { + return res.badRequest('ids must be an array of positive integers.'); + } + + // De-duplicate so that repeated IDs don't cause a false 403 + const ids = [...new Set(rawIds)]; + + if (ids.length > MAX_IDS) { + return res.badRequest( + `ids array exceeds the maximum allowed length of ${MAX_IDS}.` + ); + } + + // Verify ownership: all requested IDs must belong to the authenticated user. + // Returns 403 for both "not yours" and "doesn't exist" to prevent ID enumeration. + const ownedCount = await TNotification.count({ + id: { in: ids }, + notified: req.token.id, + }); + + if (ownedCount !== ids.length) { + return res.forbidden( + 'You cannot mark as read notifications that do not belong to you.' + ); + } + + // Batch update only the unread ones + await TNotification.update({ + id: { in: ids }, + dateReadAt: null, + }).set({ dateReadAt: new Date() }); + } else { + // Mark all unread notifications for the authenticated user as read + await TNotification.update({ + notified: req.token.id, + dateReadAt: null, + }).set({ dateReadAt: new Date() }); + } + + return res.status(204).send(); +}; diff --git a/assets/swaggerV1.yaml b/assets/swaggerV1.yaml index 7ff9ade5d..3df143d83 100644 --- a/assets/swaggerV1.yaml +++ b/assets/swaggerV1.yaml @@ -7089,6 +7089,45 @@ paths: '500': description: The signing secret is not configured for the requested product (server misconfiguration). + '/notifications/read': + put: + tags: + - notifications + summary: Batch mark notifications as read + description: >- + Marks multiple notifications as read for the authenticated user. + If `ids` is provided and non-empty, only those specific notifications are marked as read. + If `ids` is omitted or empty, all unread notifications for the authenticated user are marked as read. + Already-read notifications in the list are silently skipped (idempotent). + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + minimum: 1 + description: >- + Specific notification IDs to mark as read. If omitted or empty, + all unread notifications for the authenticated user are marked as read. + Maximum 1000 IDs per request. + example: [1, 2, 3] + responses: + '204': + description: Notifications successfully marked as read. + '400': + description: Invalid request body (ids must be an array of positive integers). + '401': + description: Bearer token not found or invalid. + '403': + description: One or more notification IDs do not belong to the authenticated user. + components: parameters: diff --git a/config/policies.js b/config/policies.js index 0a8b2a2b8..66567ae4f 100644 --- a/config/policies.js +++ b/config/policies.js @@ -268,6 +268,7 @@ module.exports.policies = { 'v1/notification/count-unread': 'tokenAuth', 'v1/notification/find-all': 'tokenAuth', 'v1/notification/mark-as-read': 'tokenAuth', + 'v1/notification/mark-as-read-batch': 'tokenAuth', // Organizations 'v1/organization/count': true, diff --git a/config/routes.js b/config/routes.js index 4af11d567..1a25bff5c 100644 --- a/config/routes.js +++ b/config/routes.js @@ -154,6 +154,7 @@ module.exports.routes = { 'GET /api/v1/notifications': 'v1/notification/find-all', 'POST /api/v1/notifications/:notificationId/read': 'v1/notification/mark-as-read', + 'PUT /api/v1/notifications/read': 'v1/notification/mark-as-read-batch', // Cave 'DELETE /api/v1/caves/:id': 'v1/cave/delete', diff --git a/test/integration/4_routes/Notifications/read-batch.test.js b/test/integration/4_routes/Notifications/read-batch.test.js new file mode 100644 index 000000000..c6905bcc5 --- /dev/null +++ b/test/integration/4_routes/Notifications/read-batch.test.js @@ -0,0 +1,140 @@ +const supertest = require('supertest'); +const should = require('should'); +const AuthTokenService = require('../../AuthTokenService'); + +describe('Notifications batch mark-as-read', () => { + let adminToken; + + before(async () => { + adminToken = await AuthTokenService.getRawBearerAdminToken(); + }); + + afterEach(async () => { + // Reset all notifications to their fixture state + await TNotification.updateOne(1).set({ dateReadAt: null }); + await TNotification.updateOne(2).set({ dateReadAt: null }); + await TNotification.updateOne(3).set({ + dateReadAt: new Date('2022-09-01T12:05:52Z'), + }); + await TNotification.updateOne(4).set({ dateReadAt: null }); + }); + + describe('PUT /api/v1/notifications/read — mark all', () => { + it('should return 401 when not authenticated', (done) => { + supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .expect(401, done); + }); + + it('should return 204 and mark all unread notifications as read when body is empty', async () => { + await supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({}) + .expect(204); + + // Verify admin's unread notifications are now read + const n1 = await TNotification.findOne(1); + const n2 = await TNotification.findOne(2); + should(n1.dateReadAt).not.be.null(); + should(n2.dateReadAt).not.be.null(); + + // Verify notification belonging to another user is untouched + const n4 = await TNotification.findOne(4); + should(n4.dateReadAt).be.null(); + }); + + it('should return 204 and mark all unread notifications as read when ids is empty array', async () => { + await supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [] }) + .expect(204); + + const n1 = await TNotification.findOne(1); + const n2 = await TNotification.findOne(2); + should(n1.dateReadAt).not.be.null(); + should(n2.dateReadAt).not.be.null(); + }); + }); + + describe('PUT /api/v1/notifications/read — mark by IDs', () => { + it('should return 204 and mark only specified notifications as read', async () => { + await supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [1] }) + .expect(204); + + const n1 = await TNotification.findOne(1); + should(n1.dateReadAt).not.be.null(); + + // Notification 2 (also admin's, unread) should remain unread + const n2 = await TNotification.findOne(2); + should(n2.dateReadAt).be.null(); + }); + + it('should return 403 when any ID does not belong to the authenticated user', (done) => { + supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [1, 4] }) // ID 4 belongs to caver 2 + .expect(403, done); + }); + + it('should return 403 when a non-existent ID is provided', (done) => { + supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [1, 999999] }) + .expect(403, done); + }); + + it('should be idempotent — already-read notifications are silently skipped', async () => { + // Notification 3 is already read in fixtures + await supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [3] }) + .expect(204); + + const n3 = await TNotification.findOne(3); + should(n3.dateReadAt).not.be.null(); + }); + + it('should return 400 when ids contains invalid values', (done) => { + supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: [-1, 'abc'] }) + .expect(400, done); + }); + + it('should return 400 when ids array exceeds maximum length', (done) => { + const tooManyIds = Array.from({ length: 1001 }, (_, i) => i + 1); + supertest(sails.hooks.http.app) + .put('/api/v1/notifications/read') + .set('Authorization', adminToken) + .set('Content-type', 'application/json') + .set('Accept', 'application/json') + .send({ ids: tooManyIds }) + .expect(400, done); + }); + }); +});