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
18 changes: 18 additions & 0 deletions MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,24 @@ Rate-limit middleware now keys authenticated requests by `user._id` (with `req.i

---

## Tasks stats endpoint requires JWT + org scope (2026-04-08)

`GET /api/tasks/stats` now requires authentication and organization context, consistent with all other task endpoints.

### What changed

- `modules/tasks/routes/tasks.routes.js` — added JWT + `resolveOrganization` + `isAllowed` middleware
- `modules/tasks/controllers/tasks.controller.js` — passes `req.organization` to service, uses try/catch
- `modules/tasks/services/tasks.service.js` — `stats()` accepts organization and filters by `organizationId`
- `modules/tasks/repositories/tasks.repository.js` — `stats()` uses `countDocuments(filter)` instead of `estimatedDocumentCount()`

### Action for downstream
1. Any unauthenticated call to `/api/tasks/stats` will now return `401`
2. Authenticated calls return the count scoped to the user's current organization
3. Run `/update-stack` to pull the change

---

## Remove dead scripts — ci/ssl, crons, db/dump (2026-04-07)

Dead scripts and dev-local data removed from the stack. Downstream projects may have local copies or npm scripts referencing these.
Expand Down
4 changes: 2 additions & 2 deletions modules/auth/tests/auth.authorization.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ describe('Authorization integration tests:', () => {
await publicAgent.get('/api/tasks').expect(401);
});

test('GET /api/tasks/stats should return 200 for guests', async () => {
await publicAgent.get('/api/tasks/stats').expect(200);
test('GET /api/tasks/stats should return 401 for guests (auth required)', async () => {
await publicAgent.get('/api/tasks/stats').expect(401);
});

test('GET /api/users/stats should return 200 for guests', async () => {
Expand Down
8 changes: 4 additions & 4 deletions modules/tasks/controllers/tasks.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ const remove = async (req, res) => {
* @throws Will throw an error if the task service fails to fetch the statistics
*/
const stats = async (req, res) => {
const data = await TasksService.stats();
if (!data.err) {
try {
const data = await TasksService.stats(req.organization);
responses.success(res, 'tasks stats')(data);
} else {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(data.err))(data.err);
} catch (err) {
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
}
};

Expand Down
7 changes: 4 additions & 3 deletions modules/tasks/repositories/tasks.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ const deleteMany = (filter) => {

/**
* @function stats
* @description Data access operation to get the estimated count of documents in the tasks collection.
* @returns {Promise<number>} estimated document count
* @description Data access operation to count tasks matching the given filter.
* @param {Object} [filter={}] - Optional filter (e.g. { organizationId }).
* @returns {Promise<number>} document count
*/
const stats = () => Task.estimatedDocumentCount().exec();
const stats = (filter = {}) => Task.countDocuments(filter).exec();

/**
* @function push
Expand Down
7 changes: 5 additions & 2 deletions modules/tasks/routes/tasks.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ import tasksSchema from '../models/tasks.schema.js';
* Routes
*/
export default (app) => {
// stats — public aggregate endpoint, no auth required
app.route('/api/tasks/stats').get(tasks.stats);
// stats — org-scoped aggregate endpoint
app
.route('/api/tasks/stats')
.all(passport.authenticate('jwt', { session: false }), organization.resolveOrganization, policy.isAllowed)
.get(tasks.stats);

// list & post
app
Expand Down
8 changes: 6 additions & 2 deletions modules/tasks/services/tasks.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,14 @@ const remove = async (task) => {
/**
* @function stats
* @description Service to fetch statistical data about tasks in the database.
* When an organization context is provided, only tasks belonging to that
* organization are counted.
* @param {Object} [organization] - Optional organization document whose _id is used to filter.
* @returns {Promise} A promise resolving to the statistical data.
*/
const stats = async () => {
const result = await TasksRepository.stats();
const stats = async (organization) => {
const filter = organization ? { organizationId: organization._id } : {};
const result = await TasksRepository.stats(filter);
return result;
};

Expand Down
37 changes: 27 additions & 10 deletions modules/tasks/tests/tasks.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('Tasks integration tests:', () => {
const originalOrgEnabled = config.organizations.enabled;
let TasksService;
let TasksDataService;
let app; // Express app instance for fresh (unauthenticated) requests
let agent;
let user;
let _user;
Expand All @@ -33,7 +34,8 @@ describe('Tasks integration tests:', () => {
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
TasksService = (await import(path.resolve('./modules/tasks/services/tasks.service.js'))).default;
TasksDataService = (await import(path.resolve('./modules/tasks/services/tasks.data.service.js'))).default;
agent = request.agent(init.app);
app = init.app;
agent = request.agent(app);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
Expand Down Expand Up @@ -253,6 +255,24 @@ describe('Tasks integration tests:', () => {
}
});

test('should be able to get tasks stats when authenticated (org-scoped)', async () => {
try {
const tasksResult = await agent.get('/api/tasks').expect(200);
expect(tasksResult.body.type).toBe('success');
expect(tasksResult.body.message).toBe('task list');
expect(tasksResult.body.data).toBeInstanceOf(Array);

const result = await agent.get('/api/tasks/stats').expect(200);
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('tasks stats');
expect(typeof result.body.data).toBe('number');
expect(result.body.data).toBe(tasksResult.body.data.length);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
}
});

afterEach(async () => {
// del task
try {
Expand All @@ -273,7 +293,7 @@ describe('Tasks integration tests:', () => {
describe('Logout', () => {
test('should not be able to save a task', async () => {
try {
const result = await agent.post('/api/tasks').send(_tasks[0]).expect(401);
const result = await request(app).post('/api/tasks').send(_tasks[0]).expect(401);
expect(result.error.text).toBe('Unauthorized');
} catch (err) {
expect(err).toBeFalsy();
Expand All @@ -282,20 +302,17 @@ describe('Tasks integration tests:', () => {
});

test('should not be able to get list of tasks without auth', async () => {
// task list now requires authentication
try {
await agent.get('/api/tasks').expect(401);
await request(app).get('/api/tasks').expect(401);
} catch (err) {
console.log(err);
expect(err).toBeFalsy();
}
});

test('should be able to get a tasks stats', async () => {
test('should not be able to get tasks stats without auth', async () => {
try {
const result = await agent.get('/api/tasks/stats').expect(200);
expect(result.body.type).toBe('success');
expect(result.body.message).toBe('tasks stats');
await request(app).get('/api/tasks/stats').expect(401);
} catch (err) {
expect(err).toBeFalsy();
console.log(err);
Expand Down Expand Up @@ -442,8 +459,8 @@ describe('Tasks integration tests:', () => {
expect(result.body.description).toBe('DB error.');
});

test('should return 422 when stats returns an error', async () => {
jest.spyOn(TasksService, 'stats').mockResolvedValueOnce({ err: new Error('DB error') });
test('should return 422 when stats fails', async () => {
jest.spyOn(TasksService, 'stats').mockRejectedValueOnce(new Error('DB error'));
const result = await agent.get('/api/tasks/stats').expect(422);
expect(result.body.type).toBe('error');
expect(result.body.message).toBe('Unprocessable Entity');
Expand Down
Loading