Skip to content
Merged
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
49 changes: 49 additions & 0 deletions api/controllers/v1/notification/mark-as-read-batch.js
Original file line number Diff line number Diff line change
@@ -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();
};
39 changes: 39 additions & 0 deletions assets/swaggerV1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions config/policies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions config/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
140 changes: 140 additions & 0 deletions test/integration/4_routes/Notifications/read-batch.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});