From 8de067d362ec233db9c808eeddcd4bb664c94e10 Mon Sep 17 00:00:00 2001 From: Ayush Date: Thu, 4 Jun 2026 00:44:05 +0530 Subject: [PATCH 1/2] feat(public-api): migrate controllers to AppError and update tests --- .../src/__tests__/aggregation.test.js | 36 +-- .../__tests__/data.controller.read.test.js | 46 ++- .../src/__tests__/health.route.test.js | 26 +- .../src/__tests__/mail.controller.test.js | 88 ++--- .../src/__tests__/softDelete.test.js | 25 +- .../src/__tests__/storage.controller.test.js | 204 ++++++------ .../src/__tests__/userAuth.email.test.js | 113 ++++--- .../src/__tests__/userAuth.refresh.test.js | 57 ++-- .../src/__tests__/userAuth.social.test.js | 66 ++-- apps/public-api/src/app.js | 2 +- .../src/controllers/data.controller.js | 119 +++---- .../src/controllers/health.controller.js | 15 +- .../src/controllers/mail.controller.js | 300 ++++++++---------- .../src/controllers/schema.controller.js | 32 +- .../src/controllers/storage.controller.js | 97 ++---- .../src/controllers/userAuth.controller.js | 271 +++++++--------- 16 files changed, 707 insertions(+), 790 deletions(-) diff --git a/apps/public-api/src/__tests__/aggregation.test.js b/apps/public-api/src/__tests__/aggregation.test.js index 150d66a91..4fd6a82a1 100644 --- a/apps/public-api/src/__tests__/aggregation.test.js +++ b/apps/public-api/src/__tests__/aggregation.test.js @@ -3,7 +3,9 @@ const mockAggregate = jest.fn(); jest.mock('@urbackend/common', () => ({ - sanitize: (v) => v, + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, +sanitize: (v) => v, Project: {}, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => ({ @@ -15,6 +17,7 @@ jest.mock('@urbackend/common', () => ({ aggregateSchema: require('../../../../packages/common/src/utils/input.validation').aggregateSchema, })); +const { AppError } = require('@urbackend/common'); const { aggregateData } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -50,16 +53,19 @@ function makeRes() { } describe('aggregateData controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); mockAggregate.mockResolvedValue([{ _id: 'published', count: 2 }]); + next = jest.fn(); }); test('executes a valid aggregation pipeline', async () => { const req = makeReq({ query: {} }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: { isDeleted: { $ne: true } } }, @@ -86,7 +92,7 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: { userId: 'user_1', isDeleted: { $ne: true } } }, @@ -103,7 +109,7 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).toHaveBeenCalledWith([ { $match: {} }, // softDeleteFilter should be empty @@ -118,15 +124,12 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ - success: false, - data: {}, - message: 'Aggregation pipeline contains blocked stage.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Aggregation pipeline contains blocked stage.'); }); test('rejects invalid pipeline payloads', async () => { @@ -135,14 +138,11 @@ describe('aggregateData controller', () => { }); const res = makeRes(); - await aggregateData(req, res); + await aggregateData(req, res, next); expect(mockAggregate).not.toHaveBeenCalled(); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ - success: false, - data: {}, - message: 'Invalid input: expected array, received object', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid input: expected array, received object'); }); }); diff --git a/apps/public-api/src/__tests__/data.controller.read.test.js b/apps/public-api/src/__tests__/data.controller.read.test.js index dada8a21f..22a7e3c31 100644 --- a/apps/public-api/src/__tests__/data.controller.read.test.js +++ b/apps/public-api/src/__tests__/data.controller.read.test.js @@ -24,7 +24,9 @@ const mockQueryEngine = jest.fn((query) => { }); jest.mock('@urbackend/common', () => ({ - sanitize: (v) => v, + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, +sanitize: (v) => v, Project: {}, getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn((connection, collectionConfig, projectId, isExternal) => ({ @@ -57,6 +59,7 @@ jest.mock('@urbackend/common', () => ({ validateUpdateData: jest.fn(), })); +const { AppError } = require('@urbackend/common'); const { getAllData, getSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -92,16 +95,19 @@ function makeRes() { } describe('data.controller read RLS filters', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); mockEnginePopulate.mockClear(); + next = jest.fn(); }); test('getAllData applies rlsFilter to find()', async () => { const req = makeReq({ rlsFilter: { userId: 'user_1' } }); const res = makeRes(); - await getAllData(req, res); + await getAllData(req, res, next); expect(mockFind).toHaveBeenCalledWith(); expect(mockAnd).toHaveBeenCalledWith([{ userId: 'user_1' }]); @@ -111,8 +117,7 @@ describe('data.controller read RLS filters', () => { test('getSingleDoc applies rlsFilter to findOne()', async () => { const req = makeReq({ rlsFilter: { userId: 'user_1' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalledWith({ $and: [ @@ -127,8 +132,7 @@ describe('data.controller read RLS filters', () => { test('getAllData calls engine.populate() when populate param is provided', async () => { const req = makeReq({ query: { populate: 'author,comments' } }); const res = makeRes(); - - await getAllData(req, res); + await getAllData(req, res, next); expect(mockQueryEngine).toHaveBeenCalled(); expect(mockEnginePopulate).toHaveBeenCalled(); @@ -137,15 +141,14 @@ describe('data.controller read RLS filters', () => { test('getAllData does not forward populate/expand to Mongo filter', async () => { const req = makeReq({ query: { populate: 'author', expand: 'category', title: 'hello' } }); const res = makeRes(); - - await getAllData(req, res); + await getAllData(req, res, next); // mockFind is called with the raw Mongoose query (no args for Model.find()) // The real filter exclusion is tested via the QueryEngine directly, // but we confirm find() was invoked and the request did not error out. expect(mockFind).toHaveBeenCalledWith(); expect(res.json).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); }); test('getAllData returns 400 when QueryEngine throws query validation error', async () => { @@ -165,21 +168,17 @@ describe('data.controller read RLS filters', () => { const req = makeReq({ query: { name_regex: '[' } }); const res = makeRes(); - await getAllData(req, res); + await getAllData(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid regex pattern for "name_regex".', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toContain('Invalid regex pattern for "name_regex".'); }); test('getSingleDoc calls populate on the query', async () => { const req = makeReq({ query: { populate: 'author' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockPopulate).toHaveBeenCalledWith('author'); expect(res.json).toHaveBeenCalled(); @@ -188,20 +187,18 @@ describe('data.controller read RLS filters', () => { test('getSingleDoc handles array-format populate param without crashing', async () => { const req = makeReq({ query: { populate: ['author', 'category'] } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockPopulate).toHaveBeenCalledWith('author'); expect(mockPopulate).toHaveBeenCalledWith('category'); expect(res.json).toHaveBeenCalled(); - expect(res.status).not.toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); }); test('getSingleDoc excludes soft-deleted documents by default', async () => { const req = makeReq(); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalled(); const findOneArgs = mockFindOne.mock.calls[0][0]; @@ -219,8 +216,7 @@ describe('data.controller read RLS filters', () => { rlsFilter: { ownerId: 'user_123' } }); const res = makeRes(); - - await getSingleDoc(req, res); + await getSingleDoc(req, res, next); expect(mockFindOne).toHaveBeenCalled(); const findOneArgs = mockFindOne.mock.calls[0][0]; diff --git a/apps/public-api/src/__tests__/health.route.test.js b/apps/public-api/src/__tests__/health.route.test.js index c846204dc..819207540 100644 --- a/apps/public-api/src/__tests__/health.route.test.js +++ b/apps/public-api/src/__tests__/health.route.test.js @@ -12,6 +12,8 @@ jest.mock('mongoose', () => ({ })); jest.mock('@urbackend/common', () => ({ + AppError: class AppError extends Error { constructor(code, msg, errTitle) { super(msg); this.statusCode=code; this.error=errTitle||'Error'; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, redis: mockRedis, })); @@ -31,18 +33,22 @@ describe('health route', () => { app = express(); app.use('/api/health', healthRoute); + app.use((err, req, res, next) => { + res.status(err.statusCode || 500).json({ success: false, error: err.error, message: err.message }); + }); }); test('returns ok when mongodb and redis are connected', async () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); - expect(res.body.dependencies).toEqual({ + expect(res.body.success).toBe(true); + expect(res.body.data.status).toBe('ok'); + expect(res.body.data.dependencies).toEqual({ mongodb: 'connected', redis: 'connected', }); - expect(typeof res.body.timestamp).toBe('string'); + expect(typeof res.body.data.timestamp).toBe('string'); expect(mockRedis.ping).toHaveBeenCalledTimes(1); }); @@ -52,11 +58,8 @@ describe('health route', () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(503); - expect(res.body.status).toBe('error'); - expect(res.body.dependencies).toEqual({ - mongodb: 'disconnected', - redis: 'connected', - }); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Service unavailable'); }); test('returns error when redis is not responsive', async () => { @@ -65,10 +68,7 @@ describe('health route', () => { const res = await request(app).get('/api/health'); expect(res.status).toBe(503); - expect(res.body.status).toBe('error'); - expect(res.body.dependencies).toEqual({ - mongodb: 'connected', - redis: 'disconnected', - }); + expect(res.body.success).toBe(false); + expect(res.body.message).toBe('Service unavailable'); }); }); diff --git a/apps/public-api/src/__tests__/mail.controller.test.js b/apps/public-api/src/__tests__/mail.controller.test.js index 0aa5f73b2..38347283a 100644 --- a/apps/public-api/src/__tests__/mail.controller.test.js +++ b/apps/public-api/src/__tests__/mail.controller.test.js @@ -77,10 +77,17 @@ jest.mock('@urbackend/common', () => { })), countDocuments: jest.fn().mockResolvedValue(0), }, + AppError: class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); -const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog } = require('@urbackend/common'); +const { Project, decrypt, redis, publicEmailQueue, MailTemplate, MailLog, AppError } = require('@urbackend/common'); const mailController = require('../controllers/mail.controller'); const originalResendApiKey2 = process.env.RESEND_API_KEY_2; @@ -107,8 +114,11 @@ const mockProjectConfig = (payload) => { }; describe('mail.controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); + next = jest.fn(); process.env.RESEND_API_KEY = 'default-key'; delete process.env.RESEND_API_KEY_2; process.env.EMAIL_FROM = 'mail@urbackend.app'; @@ -131,7 +141,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue('byok-key'); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(redis.eval).toHaveBeenCalledTimes(1); expect(res.status).toHaveBeenCalledWith(200); @@ -161,7 +171,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(2); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -189,14 +199,12 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(101); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(redis.decr).toHaveBeenCalledTimes(1); - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: 'Monthly mail limit exceeded.', - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(429); + expect(next.mock.calls[0][0].message).toBe('Monthly mail limit exceeded.'); }); test('renders and sends a mail template with variables', async () => { @@ -224,7 +232,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ @@ -270,7 +278,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); // Assert project-scope query was made first expect(MailTemplate.findOne).toHaveBeenCalledWith(expect.objectContaining({ @@ -334,7 +342,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue(null); redis.eval.mockResolvedValue(1); - await mailController.sendMail(req, res); + await mailController.sendMail(req, res, next); // Assert project-scope was queried first, then global fallback expect(MailTemplate.findOne).toHaveBeenNthCalledWith(1, expect.objectContaining({ @@ -379,7 +387,7 @@ describe('mail.controller', () => { removeAllListeners: jest.fn(), close: jest.fn() })) - })); + }), { virtual: true }); const mockRedis = { eval: jest.fn().mockResolvedValue(0) }; jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis); @@ -415,7 +423,7 @@ describe('mail.controller', () => { removeAllListeners: jest.fn(), close: jest.fn() })) - })); + }), { virtual: true }); const mockRedis = { eval: jest.fn().mockResolvedValue(0) }; jest.doMock('../../../../packages/common/src/config/redis', () => mockRedis); @@ -444,9 +452,11 @@ describe('mail.controller', () => { throw new Error('invalid signature'); }); - await mailController.handleResendWebhook(req, res); + await mailController.handleResendWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Webhook signature verification failed.'); expect(MailLog.updateOne).not.toHaveBeenCalled(); }); @@ -461,7 +471,7 @@ describe('mail.controller', () => { data: { email_id: 're_123' } }); - await mailController.handleResendWebhook(req, res); + await mailController.handleResendWebhook(req, res, next); expect(MailLog.updateOne).toHaveBeenCalledWith( { resendEmailId: 're_123' }, @@ -484,10 +494,11 @@ describe('mail.controller', () => { error: { statusCode: 503, message: 'Provider unavailable' } }); - await mailController.sendBatchMail(req, res); + await mailController.sendBatchMail(req, res, next); expect(redis.decr).toHaveBeenCalledWith(expect.stringContaining('project:mail:count:proj_1:')); - expect(res.status).toHaveBeenCalledWith(503); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(503); }); test('enforces BYOK gate for audience creation', async () => { @@ -498,9 +509,10 @@ describe('mail.controller', () => { mockProjectConfig({ _id: 'proj_1', resendApiKey: null }); decrypt.mockReturnValue(null); - await mailController.createAudience(req, res); + await mailController.createAudience(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); expect(mockResendClient.audiences.create).not.toHaveBeenCalled(); }); @@ -513,9 +525,10 @@ describe('mail.controller', () => { mockProjectConfig({ _id: 'proj_1', resendApiKey: { encrypted: 'x', iv: 'y', tag: 'z' } }); decrypt.mockReturnValue('byok-key'); - await mailController.createBroadcast(req, res); + await mailController.createBroadcast(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); expect(mockResendClient.broadcasts.create).not.toHaveBeenCalled(); }); @@ -529,7 +542,7 @@ describe('mail.controller', () => { decrypt.mockReturnValue('byok-key'); mockResendClient.broadcasts.create.mockResolvedValue({ data: { id: 'b_1' }, error: null }); - await mailController.createBroadcast(req, res); + await mailController.createBroadcast(req, res, next); expect(mockResendClient.broadcasts.create).toHaveBeenCalledWith(expect.objectContaining({ audienceId: 'aud_123' })); expect(res.status).toHaveBeenCalledWith(200); @@ -550,7 +563,7 @@ describe('mail.controller', () => { const sortMock = jest.fn().mockReturnValue({ skip: skipMock }); MailLog.find.mockReturnValueOnce({ sort: sortMock }); - await mailController.getMailLogs(req, res); + await mailController.getMailLogs(req, res, next); expect(MailLog.countDocuments).toHaveBeenCalledWith({ projectId: 'proj_1' }); expect(MailLog.find).toHaveBeenCalledWith({ projectId: 'proj_1' }); @@ -558,7 +571,7 @@ describe('mail.controller', () => { expect(skipMock).toHaveBeenCalledWith(0); expect(limitMock).toHaveBeenCalledWith(50); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: { items: mockLogs, @@ -567,7 +580,7 @@ describe('mail.controller', () => { limit: 50, }, message: 'Mail logs retrieved successfully.', - }); + })); }); test('honors custom page and limit parameters', async () => { @@ -584,12 +597,12 @@ describe('mail.controller', () => { const sortMock = jest.fn().mockReturnValue({ skip: skipMock }); MailLog.find.mockReturnValueOnce({ sort: sortMock }); - await mailController.getMailLogs(req, res); + await mailController.getMailLogs(req, res, next); expect(skipMock).toHaveBeenCalledWith(20); expect(limitMock).toHaveBeenCalledWith(10); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: { items: mockLogs, @@ -598,7 +611,7 @@ describe('mail.controller', () => { limit: 10, }, message: 'Mail logs retrieved successfully.', - }); + })); }); test('caps the limit to 100 and boundaries check page/limit values', async () => { @@ -615,12 +628,12 @@ describe('mail.controller', () => { const sortMock = jest.fn().mockReturnValue({ skip: skipMock }); MailLog.find.mockReturnValueOnce({ sort: sortMock }); - await mailController.getMailLogs(req, res); + await mailController.getMailLogs(req, res, next); expect(skipMock).toHaveBeenCalledWith(0); // page -5 -> math.max(1, -5) -> page 1 -> skip 0 expect(limitMock).toHaveBeenCalledWith(100); // limit 500 capped at 100 expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: { items: mockLogs, @@ -629,7 +642,7 @@ describe('mail.controller', () => { limit: 100, }, message: 'Mail logs retrieved successfully.', - }); + })); }); test('returns 401 when project context is missing', async () => { @@ -637,13 +650,10 @@ describe('mail.controller', () => { delete req.project; const res = makeRes(); - await mailController.getMailLogs(req, res); + await mailController.getMailLogs(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: 'Project context missing.', - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); }); }); diff --git a/apps/public-api/src/__tests__/softDelete.test.js b/apps/public-api/src/__tests__/softDelete.test.js index 39e06f2ed..c67596b68 100644 --- a/apps/public-api/src/__tests__/softDelete.test.js +++ b/apps/public-api/src/__tests__/softDelete.test.js @@ -30,10 +30,13 @@ jest.mock('@urbackend/common', () => { super(message); this.statusCode = statusCode; } - } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); +const { AppError } = require('@urbackend/common'); + const { deleteSingleDoc, recoverSingleDoc } = require('../controllers/data.controller'); function makeReq(overrides = {}) { @@ -60,8 +63,11 @@ function makeRes() { } describe('Soft Delete in data.controller', () => { + let next; + beforeEach(() => { jest.clearAllMocks(); + next = jest.fn(); }); test('deleteSingleDoc sets isDeleted: true instead of hard deleting', async () => { @@ -73,8 +79,7 @@ describe('Soft Delete in data.controller', () => { mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(doc) }); - - await deleteSingleDoc(req, res); + await deleteSingleDoc(req, res, next); expect(mockFindOneAndUpdate).toHaveBeenCalledWith( expect.objectContaining({ _id: '507f1f77bcf86cd799439011', isDeleted: { $ne: true } }), @@ -102,11 +107,11 @@ describe('Soft Delete in data.controller', () => { mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); + await deleteSingleDoc(req, res, next); - await deleteSingleDoc(req, res); - - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Document not found.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); + expect(next.mock.calls[0][0].message).toBe("Document not found."); }); test('recoverSingleDoc restores a soft-deleted document', async () => { @@ -155,8 +160,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 404 if document is not in trash', async () => { const req = makeReq(); const res = makeRes(); - const next = jest.fn(); - mockFindOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) }); @@ -172,8 +175,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 409 if document restoration causes a unique field conflict', async () => { const req = makeReq(); const res = makeRes(); - const next = jest.fn(); - const error = new Error('Duplicate key'); error.code = 11000; mockFindOneAndUpdate.mockReturnValue({ @@ -191,8 +192,6 @@ describe('Soft Delete in data.controller', () => { test('recoverSingleDoc returns 400 if ID is invalid', async () => { const req = makeReq({ params: { collectionName: 'posts', id: 'invalid-id' } }); const res = makeRes(); - const next = jest.fn(); - await recoverSingleDoc(req, res, next); expect(next).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index 9d33d86de..b45e2f849 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -36,6 +36,14 @@ jest.mock('@urbackend/common', () => { incrWithTtlAtomic: jest.fn().mockResolvedValue(true), redis: { status: 'ready', eval: jest.fn() }, __mockStorageFrom: mockStorageFrom, // expose for assertions + AppError: class AppError extends Error { + constructor(statusCode, message, type) { + super(message); + this.statusCode = statusCode; + if (type) this.type = type; + } + }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, }; }); @@ -43,7 +51,7 @@ jest.mock('@urbackend/common', () => { // Import module under test after mocks // --------------------------------------------------------------------------- -const { getStorage, Project, isProjectStorageExternal, getBucket, getPresignedUploadUrl, verifyUploadedFile, __mockStorageFrom: mockStorageFrom } = require('@urbackend/common'); +const { getStorage, Project, isProjectStorageExternal, getBucket, getPresignedUploadUrl, verifyUploadedFile, __mockStorageFrom: mockStorageFrom, AppError } = require('@urbackend/common'); const storageController = require('../controllers/storage.controller'); // --------------------------------------------------------------------------- @@ -78,9 +86,11 @@ const makeFile = (overrides = {}) => ({ // --------------------------------------------------------------------------- describe('storage.controller', () => { + let next; beforeEach(() => { jest.clearAllMocks(); process.env.NODE_ENV = 'test'; + next = jest.fn(); }); describe('uploadFile', () => { @@ -88,10 +98,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: null }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'No file uploaded.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('No file uploaded.'); }); test('returns 413 when file exceeds MAX_FILE_SIZE', async () => { @@ -101,10 +112,11 @@ describe('storage.controller', () => { }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(413); - expect(res.json).toHaveBeenCalledWith({ error: 'File size exceeds limit.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(413); + expect(next.mock.calls[0][0].message).toBe('File size exceeds limit.'); }); test('returns 403 when internal storage quota is exceeded', async () => { @@ -114,11 +126,12 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Storage limit exceeded. Please upgrade your plan or delete some files.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Storage limit exceeded. Please upgrade your plan or delete some files.'); }); test('returns 201 and public URL on successful internal upload', async () => { @@ -130,7 +143,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalledTimes(1); // Quota reservation expect(mockStorageFrom.upload).toHaveBeenCalledWith( @@ -140,10 +153,13 @@ describe('storage.controller', () => { ); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ + success: true, message: 'File uploaded successfully', - url: 'https://mock.supabase.co/mocked-path', - path: 'project_id_1/mocked-uuid_test_file.txt', - provider: 'internal' + data: { + url: 'https://mock.supabase.co/mocked-path', + path: 'project_id_1/mocked-uuid_test_file.txt', + provider: 'internal' + } }); }); @@ -155,11 +171,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); // No quota reservation expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ provider: 'external' }) })); }); test('rolls back quota and returns 500 when upload fails', async () => { @@ -171,11 +187,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), file: makeFile() }; const res = makeRes(); - await storageController.uploadFile(req, res); + await storageController.uploadFile(req, res, next); expect(Project.updateOne).toHaveBeenCalledTimes(2); // One for reservation, one for rollback - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'File upload failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -184,30 +200,33 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: {} }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'File path is required.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('File path is required.'); }); test('returns 403 when trying to delete file belonging to different project', async () => { const req = { project: makeProject(), body: { path: 'wrong_project_id/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Access denied.'); }); test('returns 403 when trying to delete file using path traversal', async () => { const req = { project: makeProject(), body: { path: 'project_id_1/../other_project/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Access denied.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Access denied.'); }); test('returns 200 on successful internal deletion', async () => { @@ -218,7 +237,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt', limit: 1 }); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); @@ -226,7 +245,7 @@ describe('storage.controller', () => { { _id: 'project_id_1' }, { $inc: { storageUsed: -1024 } } ); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('returns 200 on successful external deletion and skips internal storageUsed update', async () => { @@ -237,11 +256,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt', limit: 1 }); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('falls back to zero fileSize when Supabase list fails', async () => { @@ -252,10 +271,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'File deleted successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); }); test('returns 500 when Supabase remove fails', async () => { @@ -266,10 +285,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { path: 'project_id_1/file.txt' } }; const res = makeRes(); - await storageController.deleteFile(req, res); + await storageController.deleteFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); // Storage used shouldn't decrement - expect(res.status).toHaveBeenCalledWith(500); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -278,10 +298,11 @@ describe('storage.controller', () => { const req = { project: null }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Project not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); + expect(next.mock.calls[0][0].message).toBe('Project not found'); }); test('deletes paginated files and resets internal storage to 0', async () => { @@ -297,18 +318,20 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file1.txt', 'project_id_1/file2.txt']); expect(Project.updateOne).toHaveBeenCalledWith( { _id: 'project_id_1' }, { $set: { storageUsed: 0 } } ); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, - deleted: 2, - provider: 'internal' - }); + data: { + deleted: 2, + provider: 'internal' + } + })); }); test('skips storageUsed reset for external provider during deleteAllFiles', async () => { @@ -323,10 +346,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ provider: 'external' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ provider: 'external' }) })); }); test('returns 500 when Supabase remove fails during deleteAllFiles', async () => { @@ -337,10 +360,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('returns 500 if pagination fetch fails', async () => { @@ -350,10 +373,10 @@ describe('storage.controller', () => { const req = { project: makeProject() }; const res = makeRes(); - await storageController.deleteAllFiles(req, res); + await storageController.deleteAllFiles(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Failed to delete files' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); }); @@ -362,10 +385,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filename: 'file.txt', contentType: 'text/plain', size: 'abc' } }; const res = makeRes(); - await storageController.requestUpload(req, res); + await storageController.requestUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'filename, contentType, and size are required.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns signed URL for requestUpload on valid input', async () => { @@ -375,11 +398,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filename: 'my_file.txt', contentType: 'text/plain', size: 1024 } }; const res = makeRes(); - await storageController.requestUpload(req, res); + await storageController.requestUpload(req, res, next); - expect(res.json).toHaveBeenCalledWith({ signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my_file.txt' }); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my_file.txt' }); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: { signedUrl: 'https://signed.example/upload', token: 'token-1', filePath: 'project_id_1/mocked-uuid_my_file.txt' } })); }); test('confirmUpload charges the verified size and rejects mismatches', async () => { @@ -391,7 +413,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(verifyUploadedFile).toHaveBeenCalledWith(req.project, 'project_id_1/file.txt'); expect(Project.updateOne).toHaveBeenCalledWith( @@ -406,7 +428,7 @@ describe('storage.controller', () => { ); expect(mockStorageFrom.getPublicUrl).toHaveBeenCalledWith('project_id_1/file.txt'); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' }) })); }); test('confirmUpload succeeds for unlimited storage plans', async () => { @@ -418,7 +440,7 @@ describe('storage.controller', () => { const req = { project: makeProject({ storageLimit: -1 }), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(Project.updateOne).toHaveBeenCalledWith( { @@ -433,9 +455,11 @@ describe('storage.controller', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: 'Upload confirmed', - path: 'project_id_1/file.txt', - provider: 'internal', - url: 'https://mock.supabase.co/project_id_1/file.txt' + data: expect.objectContaining({ + path: 'project_id_1/file.txt', + provider: 'internal', + url: 'https://mock.supabase.co/project_id_1/file.txt' + }) })); }); @@ -447,10 +471,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 1024 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Declared file size does not match uploaded file size.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); }); @@ -463,7 +487,7 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2100 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(Project.updateOne).toHaveBeenCalledWith( { @@ -476,7 +500,7 @@ describe('storage.controller', () => { { $inc: { storageUsed: 2048 } } ); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ path: 'project_id_1/file.txt', provider: 'internal' }) })); }); test('confirmUpload removes uploaded object when quota reservation fails', async () => { @@ -488,11 +512,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Internal storage limit exceeded.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('confirmUpload removes uploaded object when verification fails', async () => { @@ -503,11 +527,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('confirmUpload returns a retryable conflict when the uploaded object is not yet visible', async () => { @@ -518,14 +542,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith({ - error: 'UPLOAD_NOT_READY', - message: 'Uploaded file is not visible yet. Please retry confirmation.' - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(409); }); test('confirmUpload still returns 500 for generic verification errors', async () => { @@ -535,10 +556,10 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Upload confirmation failed' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(500); }); test('confirmUpload swallows cleanup failures during compensating delete', async () => { @@ -550,10 +571,11 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(mockStorageFrom.remove).toHaveBeenCalledWith(['project_id_1/file.txt']); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('confirmUpload returns a warning when public URL is unavailable', async () => { @@ -564,13 +586,15 @@ describe('storage.controller', () => { const req = { project: makeProject(), body: { filePath: 'project_id_1/file.txt', size: 2048 } }; const res = makeRes(); - await storageController.confirmUpload(req, res); + await storageController.confirmUpload(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - url: null, - warning: 'Cloudflare R2 requires a Public URL Host.', - provider: 'external' + data: expect.objectContaining({ + url: null, + warning: 'Cloudflare R2 requires a Public URL Host.', + provider: 'external' + }) })); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.email.test.js b/apps/public-api/src/__tests__/userAuth.email.test.js index 69f9cf9db..c46f5b8db 100644 --- a/apps/public-api/src/__tests__/userAuth.email.test.js +++ b/apps/public-api/src/__tests__/userAuth.email.test.js @@ -85,6 +85,8 @@ jest.mock('@urbackend/common', () => { getRefreshSession: jest.fn(), persistRefreshSession: jest.fn().mockResolvedValue(undefined), revokeSessionChain: jest.fn().mockResolvedValue(undefined), + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -102,7 +104,7 @@ jest.mock('../utils/refreshToken', () => ({ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); -const { redis, authEmailQueue, __mockModel: mockModel, checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); +const { AppError, redis, authEmailQueue, __mockModel: mockModel, checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); const { issueAuthTokens } = require('../utils/refreshToken'); const controller = require('../controllers/userAuth.controller'); @@ -143,7 +145,9 @@ const makeRes = () => { describe('OTP generation uses CSPRNG', () => { let randomIntSpy; - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); randomIntSpy = jest.spyOn(crypto, 'randomInt'); }); @@ -159,7 +163,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce(null); model.create.mockResolvedValueOnce({ _id: 'u1' }); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); const storedOtp = require('@urbackend/common').redis.set.mock.calls.find( @@ -175,7 +179,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce({ _id: 'u1', isVerified: false }); redis.get.mockResolvedValueOnce(null); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); const otpCall = redis.set.mock.calls.find(c => c[0].includes('otp:verification')); @@ -189,7 +193,7 @@ describe('OTP generation uses CSPRNG', () => { model.findOne.mockResolvedValueOnce({ _id: 'u1', isVerified: false }); redis.get.mockResolvedValueOnce(null); - await controller.resendVerificationOtp(req, res); + await controller.resendVerificationOtp(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); }); @@ -199,14 +203,16 @@ describe('OTP generation uses CSPRNG', () => { const res = makeRes(); require('@urbackend/common').__mockModel.findOne.mockResolvedValueOnce({ _id: 'u1' }); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(randomIntSpy).toHaveBeenCalledWith(100000, 1000000); }); }); describe('Email Authentication Flow', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); }); @@ -220,7 +226,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce(null); // User does not exist mockModel.create.mockResolvedValueOnce({ _id: 'user_123' }); - await controller.signup(req, res); + await controller.signup(req, res, next); expect(mockModel.findOne).toHaveBeenCalled(); expect(mockModel.create).toHaveBeenCalledWith(expect.objectContaining({ @@ -244,11 +250,13 @@ describe('Email Authentication Flow', () => { expect(clearLockout).toHaveBeenCalledWith('project_1', 'new@user.com'); expect(issueAuthTokens).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ token: 'signed_access_token', userId: 'user_123' - })); + }) + ); }); test('returns 400 if user already exists', async () => { @@ -259,12 +267,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', email: 'existing@user.com', isVerified: true }); - await controller.signup(req, res); + await controller.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - error: expect.stringContaining('already exists') - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); + }); test('resends verification if user exists but unverified', async () => { @@ -276,11 +283,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', email: 'unverified@user.com', isVerified: false }); redis.get.mockResolvedValueOnce(null); // No cooldown - await controller.signup(req, res); + await controller.signup(req, res, next); expect(mockModel.create).not.toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(200); + }); }); @@ -294,7 +301,7 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); mockModel.updateOne.mockResolvedValueOnce({ matchedCount: 1 }); - await controller.verifyEmail(req, res); + await controller.verifyEmail(req, res, next); expect(redis.get).toHaveBeenCalledWith(expect.stringContaining('test@user.com')); expect(mockModel.updateOne).toHaveBeenCalledWith( @@ -302,7 +309,9 @@ describe('Email Authentication Flow', () => { { $set: { isVerified: true } } ); expect(redis.del).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.any(String) }) + ); }); test('returns 400 for invalid OTP', async () => { @@ -313,10 +322,11 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); - await controller.verifyEmail(req, res); + await controller.verifyEmail(req, res, next); expect(mockModel.updateOne).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); }); @@ -336,15 +346,17 @@ describe('Email Authentication Flow', () => { }); bcrypt.compare.mockResolvedValueOnce(true); - await controller.login(req, res); + await controller.login(req, res, next); expect(checkLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(bcrypt.compare).toHaveBeenCalledWith('password123', 'hashed_pw'); expect(clearLockout).toHaveBeenCalledWith('project_1', 'test@user.com'); expect(issueAuthTokens).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ token: 'signed_access_token' - })); + }) + ); }); test('returns 400 for incorrect password', async () => { @@ -361,10 +373,11 @@ describe('Email Authentication Flow', () => { }); bcrypt.compare.mockResolvedValueOnce(false); - await controller.login(req, res); + await controller.login(req, res, next); expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'test@user.com'); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); test('returns 423 when account is already locked', async () => { @@ -375,11 +388,12 @@ describe('Email Authentication Flow', () => { checkLockout.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900 }); - await controller.login(req, res); + await controller.login(req, res, next); expect(bcrypt.compare).not.toHaveBeenCalled(); expect(recordFailedAttempt).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); test('returns 423 when failures reach lockout threshold', async () => { @@ -397,9 +411,10 @@ describe('Email Authentication Flow', () => { bcrypt.compare.mockResolvedValueOnce(false); recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); - await controller.login(req, res); + await controller.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); test('user-not-found branch records attempt and returns 423 when lockout is reached', async () => { @@ -414,10 +429,11 @@ describe('Email Authentication Flow', () => { }); recordFailedAttempt.mockResolvedValueOnce({ locked: true, retryAfterSeconds: 900, attempts: 5 }); - await controller.login(req, res); + await controller.login(req, res, next); expect(recordFailedAttempt).toHaveBeenCalledWith('project_1', 'missing@user.com'); - expect(res.status).toHaveBeenCalledWith(423); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(423); }); }); @@ -431,7 +447,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); // no cooldown - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(redis.set).toHaveBeenCalledWith(expect.stringContaining('otp:reset:reset@user.com'), expect.any(String), 'EX', 300); expect(authEmailQueue.add).toHaveBeenCalledWith('send-reset-email', expect.objectContaining({ @@ -447,15 +463,13 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('1'); // cooldown active — checked before findOne redis.ttl.mockResolvedValueOnce(45); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); expect(mockModel.findOne).not.toHaveBeenCalled(); // DB never queried expect(authEmailQueue.add).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(429); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - message: expect.stringContaining('45 seconds') - })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(429); + }); test('sets cooldown for unknown email to prevent account enumeration', async () => { @@ -465,12 +479,14 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce(null); // no cooldown mockModel.findOne.mockResolvedValueOnce(null); // user does not exist - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const cooldownCall = redis.set.mock.calls.find(c => c[0].includes('otp:cooldown:reset')); expect(cooldownCall).toBeDefined(); // cooldown set even for unknown email expect(authEmailQueue.add).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ message: expect.any(String) })); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.any(String) }) + ); }); test('sets cooldown after sending reset OTP', async () => { @@ -480,7 +496,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const cooldownCall = redis.set.mock.calls.find(c => c[0].includes('otp:cooldown:reset')); expect(cooldownCall).toBeDefined(); @@ -495,7 +511,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123' }); redis.get.mockResolvedValueOnce(null); - await controller.requestPasswordReset(req, res); + await controller.requestPasswordReset(req, res, next); const otpCall = redis.set.mock.calls.find(c => c[0].includes('otp:reset')); expect(otpCall[0]).toContain('reset@user.com'); @@ -512,7 +528,7 @@ describe('Email Authentication Flow', () => { redis.get.mockResolvedValueOnce('123456'); mockModel.updateOne.mockResolvedValueOnce({ matchedCount: 1 }); - await controller.resetPasswordUser(req, res); + await controller.resetPasswordUser(req, res, next); expect(bcrypt.hash).toHaveBeenCalledWith('newpassword123', 'salt'); expect(mockModel.updateOne).toHaveBeenCalledWith( @@ -537,7 +553,7 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', password: 'old_hashed_pw' }); bcrypt.compare.mockResolvedValueOnce(true); - await controller.changePasswordUser(req, res); + await controller.changePasswordUser(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('oldpassword', 'old_hashed_pw'); expect(bcrypt.hash).toHaveBeenCalledWith('newpassword123', 'salt'); @@ -559,10 +575,11 @@ describe('Email Authentication Flow', () => { mockModel.findOne.mockResolvedValueOnce({ _id: 'user_123', password: 'old_hashed_pw' }); bcrypt.compare.mockResolvedValueOnce(false); - await controller.changePasswordUser(req, res); + await controller.changePasswordUser(req, res, next); expect(mockModel.updateOne).not.toHaveBeenCalled(); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.refresh.test.js b/apps/public-api/src/__tests__/userAuth.refresh.test.js index 09927d46a..530d7ffe0 100644 --- a/apps/public-api/src/__tests__/userAuth.refresh.test.js +++ b/apps/public-api/src/__tests__/userAuth.refresh.test.js @@ -73,6 +73,8 @@ jest.mock('@urbackend/common', () => { getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockModel), __mockModel: mockModel, + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -87,7 +89,7 @@ jest.mock('@urbackend/common', () => { }); const bcrypt = require('bcryptjs'); -const { Project, redis, getRefreshSession, persistRefreshSession, __mockModel: mockModel } = require('@urbackend/common'); +const { AppError, Project, redis, getRefreshSession, persistRefreshSession, __mockModel: mockModel } = require('@urbackend/common'); const controller = require('../controllers/userAuth.controller'); const makeProject = () => ({ @@ -124,7 +126,9 @@ const makeRes = () => { const hashToken = (token) => crypto.createHash('sha256').update(token).digest('hex'); describe('public userAuth refresh flow', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); process.env.NODE_ENV = 'test'; }); @@ -142,7 +146,7 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.login(req, res); + await controller.login(req, res, next); expect(res.cookie).toHaveBeenCalledWith( 'refreshToken', @@ -150,25 +154,26 @@ describe('public userAuth refresh flow', () => { expect.objectContaining({ httpOnly: true }) ); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ token: 'signed_access_token', accessToken: 'signed_access_token', expiresIn: expect.any(String), }) - ); + ); }); test('refresh-token returns 401 when token is missing', async () => { const req = makeReq({ headers: {} }); const res = makeRes(); - await controller.refreshToken(req, res); + await controller.refreshToken(req, res, next); expect(res.clearCookie).toHaveBeenCalledWith( 'refreshToken', expect.objectContaining({ httpOnly: true }) ); - expect(res.status).toHaveBeenCalledWith(401); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(401); }); test('refresh-token rotates and returns new access token', async () => { @@ -201,16 +206,16 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.refreshToken(req, res); + await controller.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ token: 'signed_access_token', accessToken: 'signed_access_token', refreshToken: expect.any(String), }) - ); + ); }); test('refresh-token returns 403 when user is soft-deleted', async () => { @@ -236,13 +241,17 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.refreshToken(req, res); + const next = jest.fn(); + await controller.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ success: false, data: {}, message: expect.stringContaining('deletion') }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); + expect(next.mock.calls[next.mock.calls.length - 1][0].message).toMatch(/deletion/); expect(res.clearCookie).toHaveBeenCalledWith('refreshToken', expect.any(Object)); }); + + test('logout revokes current refresh session when provided', async () => { const rawToken = 'token_2.secret_2'; const session = { @@ -262,13 +271,13 @@ describe('public userAuth refresh flow', () => { }); const res = makeRes(); - await controller.logout(req, res); + await controller.logout(req, res, next); expect(res.clearCookie).toHaveBeenCalledWith( 'refreshToken', expect.objectContaining({ httpOnly: true }) ); - expect(res.status).toHaveBeenCalledWith(200); + }); test('public profile returns only safe fields', async () => { @@ -303,17 +312,21 @@ describe('public userAuth refresh flow', () => { req.params = { username: 'yash' }; const res = makeRes(); - await controller.publicProfile(req, res); + await controller.publicProfile(req, res, next); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ + expect.objectContaining({ data: expect.objectContaining({ _id: 'user_1', username: 'yash', name: 'Yash', bio: 'builder', - }) - ); - expect(res.json).not.toHaveBeenCalledWith(expect.objectContaining({ email: 'private@example.com' })); - expect(res.json).not.toHaveBeenCalledWith(expect.objectContaining({ password: 'hashed_secret' })); + }) }) + ); + expect(res.json).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ email: 'private@example.com' }) }) + ); + expect(res.json).not.toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ password: 'hashed_secret' }) }) + ); }); }); diff --git a/apps/public-api/src/__tests__/userAuth.social.test.js b/apps/public-api/src/__tests__/userAuth.social.test.js index 5b78db782..3d25d80e7 100644 --- a/apps/public-api/src/__tests__/userAuth.social.test.js +++ b/apps/public-api/src/__tests__/userAuth.social.test.js @@ -70,6 +70,8 @@ jest.mock('@urbackend/common', () => { sanitize: jest.fn((value) => value), getConnection: jest.fn().mockResolvedValue({}), getCompiledModel: jest.fn(() => mockUsersModel), + AppError: class AppError extends Error { constructor(statusCode, message) { super(message); this.statusCode = statusCode; } }, + ApiResponse: class ApiResponse { constructor(d, m) { this.data=d; this.message=m; this.success=true; } send(res, code) { return res.status(code).json({ success: this.success, data: this.data, message: this.message }); } }, checkLockout: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0 }), recordFailedAttempt: jest.fn().mockResolvedValue({ locked: false, retryAfterSeconds: 0, attempts: 1 }), clearLockout: jest.fn().mockResolvedValue(undefined), @@ -82,7 +84,7 @@ jest.mock('@urbackend/common', () => { }; }); -const { redis } = require('@urbackend/common'); +const { AppError, redis } = require('@urbackend/common'); const { issueAuthTokens } = require('../utils/refreshToken'); const controller = require('../controllers/userAuth.controller'); @@ -185,7 +187,9 @@ const signGoogleIdToken = (claims = {}) => { }; describe('public userAuth social auth', () => { - beforeEach(() => { + let next; +beforeEach(() => { +next = jest.fn(); jest.clearAllMocks(); global.fetch = jest.fn(); process.env.FRONTEND_URL = 'http://localhost:5173'; @@ -197,7 +201,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' } }); const res = makeRes(); - await controller.startSocialAuth(req, res); + await controller.startSocialAuth(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('https://github.com/login/oauth/authorize?')); @@ -208,7 +212,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' } }); const res = makeRes(); - await controller.startSocialAuth(req, res); + await controller.startSocialAuth(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('https://accounts.google.com/o/oauth2/v2/auth?')); @@ -219,10 +223,11 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'missing' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Invalid or expired OAuth state' })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); + }); test('handleSocialAuthCallback rejects when GitHub returns no email', async () => { @@ -245,7 +250,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -277,7 +282,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'alice@example.com', @@ -317,7 +322,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.updateOne).toHaveBeenCalledWith( { _id: 'user_existing_1' }, @@ -350,7 +355,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.create).toHaveBeenCalledWith(expect.objectContaining({ email: 'alice@example.com', @@ -381,7 +386,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(mockUsersModel.updateOne).toHaveBeenCalledWith( { _id: 'user_existing_google' }, @@ -410,7 +415,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -434,7 +439,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_google', state: 'state_google' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // P2: errors now redirect to frontend instead of JSON expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); @@ -454,17 +459,16 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); - await controller.exchangeSocialRefreshToken(req, res); + await controller.exchangeSocialRefreshToken(req, res, next); expect(redis.getdel).toHaveBeenCalledWith('project:social-auth:refresh-exchange:code_123'); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith({ + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: true, data: { refreshToken: 'issued_refresh_token', }, message: 'Refresh token exchanged successfully', - }); + })); }); test('exchangeSocialRefreshToken rejects invalid or expired code', async () => { @@ -477,14 +481,10 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); - await controller.exchangeSocialRefreshToken(req, res); + await controller.exchangeSocialRefreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(400); }); test('exchangeSocialRefreshToken rejects mismatched token and deletes exchange code', async () => { @@ -500,13 +500,9 @@ describe('public userAuth social auth', () => { }; const res = makeRes(); - await controller.exchangeSocialRefreshToken(req, res); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ - success: false, - data: {}, - message: 'Invalid refresh token exchange payload', - }); + await controller.exchangeSocialRefreshToken(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[next.mock.calls.length - 1][0].statusCode).toBe(403); }); // P2: Provider error forwarding @@ -527,7 +523,7 @@ describe('public userAuth social auth', () => { }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('The+user+denied')); @@ -570,7 +566,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'github' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // With verified=true, should successfully link and redirect expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('rtCode=')); @@ -605,7 +601,7 @@ describe('public userAuth social auth', () => { const req = makeReq({ params: { provider: 'google' }, query: { code: 'code_1', state: 'state_1' } }); const res = makeRes(); - await controller.handleSocialAuthCallback(req, res); + await controller.handleSocialAuthCallback(req, res, next); // Should redirect with error because email exists but not verified for linking expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 6c6fcc933..150b2cd75 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -103,7 +103,7 @@ app.use((err, req, res, next) => { if (err.isOperational && err.statusCode) { return res.status(err.statusCode).json({ success: false, - data: {}, + error: err.error || "Error", message: err.message }); } diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index b1c940e9d..9930708e0 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -9,6 +9,7 @@ const { performance } = require('perf_hooks'); const { z } = require("zod"); const { AppError, + ApiResponse, enqueueCollectionCleanup, syncCollectionCleanup } = require("@urbackend/common"); @@ -30,7 +31,7 @@ const containsBlockedAggregationStage = (pipeline = []) => { }; // INSERT DATA -module.exports.insertData = async (req, res) => { +module.exports.insertData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -41,13 +42,13 @@ module.exports.insertData = async (req, res) => { (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const schemaRules = collectionConfig.model; const incomingData = req.body; const { error, cleanData } = validateData(incomingData, schemaRules); - if (error) return res.status(400).json({ error }); + if (error) return next(new AppError(400, error)); // Prevent manual injection of soft-delete fields delete cleanData.isDeleted; @@ -62,7 +63,7 @@ module.exports.insertData = async (req, res) => { : { ...safeData, _id: new mongoose.Types.ObjectId() }; docSize = mongoose.mongo.BSON.calculateObjectSize(docForSize); if ((project.databaseUsed || 0) + docSize > project.databaseLimit) { - return res.status(403).json({ error: "Database limit exceeded." }); + return next(new AppError(403, "Database limit exceeded.")); } } @@ -91,20 +92,17 @@ module.exports.insertData = async (req, res) => { }, { removeOnComplete: true }); if (isDebug) console.log(`[DEBUG] insert data took ${(performance.now() - start).toFixed(2)}ms`); - res.status(201).json(result); + return new ApiResponse(result).send(res, 201); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (isDuplicateKeyError(err)) { - return res.status(409).json({ - error: "Duplicate value violates unique constraint.", - details: err.message, - }); + return next(new AppError(409, "Duplicate value violates unique constraint.", err.message)); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; @@ -231,7 +229,7 @@ module.exports.bulkInsertData = async (req, res, next) => { // GET ALL DATA -module.exports.getAllData = async (req, res) => { +module.exports.getAllData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -241,14 +239,9 @@ module.exports.getAllData = async (req, res) => { const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); + if (!collectionConfig) + return next(new AppError(404, "Collection not found")); - if (!collectionConfig) { - return res.status(404).json({ - success: false, - data: {}, - message: "Collection not found", - }); - } const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -275,11 +268,7 @@ module.exports.getAllData = async (req, res) => { const count = await countQuery; - return res.status(200).json({ - success: true, - data: { count }, - message: "Count fetched successfully.", - }); + return new ApiResponse({ count }, "Count fetched successfully.").send(res, 200); } const features = new QueryEngine(Model.find(), req.query).filter(); @@ -327,48 +316,36 @@ module.exports.getAllData = async (req, res) => { limit, }; - res.json({ - success: true, - data: { - items, - ...responseMeta, - }, - message: "Data fetched successfully", - }); + return new ApiResponse({ + items, + ...responseMeta, + }, "Data fetched successfully").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (err && (err.statusCode === 400 || err.name === 'QueryFilterError')) { - return res.status(400).json({ - success: false, - data: {}, - message: err.message || "Invalid query filter.", - }); + return next(new AppError(400, err.message || "Invalid query filter.", "Query Filter Error")); } - res.status(500).json({ - success: false, - data: {}, - message: "Failed to fetch data.", - }); + return next(new AppError(500, "Failed to fetch data.")); } }; // GET SINGLE DOC -module.exports.getSingleDoc = async (req, res) => { +module.exports.getSingleDoc = async (req, res, next) => { try { const { collectionName, id } = req.params; const project = req.project; if (!isValidId(id)) - return res.status(400).json({ error: "Invalid ID format." }); + return next(new AppError(400, "Invalid ID format.")); const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -411,19 +388,19 @@ module.exports.getSingleDoc = async (req, res) => { } const doc = await query.lean(); - if (!doc) return res.status(404).json({ error: "Document not found." }); + if (!doc) return next(new AppError(404, "Document not found.")); - res.json(doc); + return new ApiResponse(doc).send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; // AGGREGATE DATA -module.exports.aggregateData = async (req, res) => { +module.exports.aggregateData = async (req, res, next) => { try { let start; if (isDebug) start = performance.now(); @@ -435,21 +412,13 @@ module.exports.aggregateData = async (req, res) => { ); if (!collectionConfig) { - return res.status(404).json({ - success: false, - data: {}, - message: "Collection not found", - }); + return next(new AppError(404, "Collection not found")); } const { pipeline } = aggregateSchema.parse(req.body || {}); if (containsBlockedAggregationStage(pipeline)) { - return res.status(400).json({ - success: false, - data: {}, - message: "Aggregation pipeline contains blocked stage.", - }); + return next(new AppError(400, "Aggregation pipeline contains blocked stage.", "Validation Error")); } const connection = await getConnection(project._id); @@ -489,29 +458,17 @@ module.exports.aggregateData = async (req, res) => { if (isDebug) console.log(`[DEBUG] aggregate took ${(performance.now() - start).toFixed(2)}ms`); - return res.status(200).json({ - success: true, - data, - message: "Aggregation executed successfully.", - }); + return new ApiResponse(data, "Aggregation executed successfully.").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid aggregation payload.", - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid aggregation payload.", "Validation Error")); } - return res.status(500).json({ - success: false, - data: {}, - message: err.message || "Failed to execute aggregation.", - }); + return next(new AppError(500, err.message || "Failed to execute aggregation.")); } }; @@ -610,7 +567,7 @@ module.exports.updateSingleData = async (req, res, next) => { payload: result }, { removeOnComplete: true }); - res.json({ success: true, data: result, message: "Updated" }); + return new ApiResponse(result, "Updated").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); @@ -629,19 +586,19 @@ module.exports.updateSingleData = async (req, res, next) => { * @param {import('express').Request} req - Express request object. * @param {import('express').Response} res - Express response object. */ -module.exports.deleteSingleDoc = async (req, res) => { +module.exports.deleteSingleDoc = async (req, res, next) => { try { const { collectionName, id } = req.params; const project = req.project; if (!isValidId(id)) - return res.status(400).json({ error: "Invalid ID format." }); + return next(new AppError(400, "Invalid ID format.")); const collectionConfig = project.collections.find( (c) => c.name === collectionName, ); if (!collectionConfig) - return res.status(404).json({ error: "Collection not found" }); + return next(new AppError(404, "Collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel( @@ -663,7 +620,7 @@ module.exports.deleteSingleDoc = async (req, res) => { ).lean(); if (!result) - return res.status(404).json({ error: "Document not found." }); + return next(new AppError(404, "Document not found.")); // We don't decrement databaseUsed here because the document still occupies space. // It will be decremented during hard delete in the background worker. @@ -680,12 +637,12 @@ module.exports.deleteSingleDoc = async (req, res) => { payload: result }, { removeOnComplete: true }); - res.json({ success: true, data: { id }, message: "Document moved to trash" }); + return new ApiResponse({ id }, "Document moved to trash").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; @@ -754,7 +711,7 @@ module.exports.recoverSingleDoc = async (req, res, next) => { console.error("Failed to sync trash cleanup job after recovery", { projectId: String(project._id), collectionName, err }); } - res.json({ success: true, data: result, message: "Document recovered from trash" }); + return new ApiResponse(result, "Document recovered from trash").send(res, 200); } catch (err) { if (process.env.NODE_ENV !== 'test') { console.error(err); diff --git a/apps/public-api/src/controllers/health.controller.js b/apps/public-api/src/controllers/health.controller.js index 750711f6c..220f2a364 100644 --- a/apps/public-api/src/controllers/health.controller.js +++ b/apps/public-api/src/controllers/health.controller.js @@ -1,8 +1,8 @@ const mongoose = require('mongoose'); -const { redis } = require('@urbackend/common'); +const { redis, ApiResponse, AppError } = require('@urbackend/common'); const REDIS_PING_TIMEOUT_MS = 500; -const getHealth = async (req, res) => { +const getHealth = async (req, res, next) => { const isMongoConnected = mongoose.connection.readyState === 1; let isRedisConnected = false; @@ -19,15 +19,20 @@ const getHealth = async (req, res) => { } const status = isMongoConnected && isRedisConnected ? 'ok' : 'error'; - - return res.status(status === 'ok' ? 200 : 503).json({ + const payload = { status, timestamp: new Date().toISOString(), dependencies: { mongodb: isMongoConnected ? 'connected' : 'disconnected', redis: isRedisConnected ? 'connected' : 'disconnected', }, - }); + }; + + if (status === 'ok') { + return new ApiResponse(payload, "Health Check Passed").send(res, 200); + } else { + return next(new AppError(503, "Service unavailable", "Health Check Failed")); + } }; module.exports = { diff --git a/apps/public-api/src/controllers/mail.controller.js b/apps/public-api/src/controllers/mail.controller.js index eb049237e..07e231ca6 100644 --- a/apps/public-api/src/controllers/mail.controller.js +++ b/apps/public-api/src/controllers/mail.controller.js @@ -1,5 +1,5 @@ const { z } = require("zod"); -const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue, MailLog } = require("@urbackend/common"); +const { Project, MailTemplate, decrypt, redis, sendMailSchema, publicEmailQueue, MailLog, AppError, ApiResponse } = require("@urbackend/common"); const { Resend } = require("resend"); const { getMonthKey, @@ -128,15 +128,11 @@ const renderTemplateString = (template, vars, { mode }) => { return out; }; -module.exports.sendMail = async (req, res) => { +module.exports.sendMail = async (req, res, next) => { let consumedQuotaKey = null; try { if (req.keyRole !== "secret") { - return res.status(403).json({ - success: false, - data: {}, - message: "Forbidden. This action requires a Secret Key (sk_live_...).", - }); + return next(new AppError(403, "Forbidden. This action requires a Secret Key (sk_live_...).")); } const { @@ -151,12 +147,12 @@ module.exports.sendMail = async (req, res) => { const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const project = await loadProjectMailConfig(projectId); if (!project) { - return res.status(404).json({ success: false, data: {}, message: "Project not found." }); + return next(new AppError(404, "Project not found.")); } const vars = @@ -224,17 +220,13 @@ module.exports.sendMail = async (req, res) => { } if (!t) { - return res.status(400).json({ success: false, data: {}, message: "Template not found." }); + return next(new AppError(400, "Template not found.")); } // Enforce Pro feature limit only for custom (project-owned) templates. if (t.projectId) { if (!req.planLimits || req.planLimits.mailTemplatesEnabled !== true) { - return res.status(403).json({ - success: false, - data: {}, - message: "Custom Email Templates are a Pro feature. Please upgrade to use this functionality." - }); + return next(new AppError(403, "Custom Email Templates are a Pro feature. Please upgrade to use this functionality.")); } } @@ -251,14 +243,14 @@ module.exports.sendMail = async (req, res) => { } if (!resolvedSubject || !resolvedSubject.trim()) { - return res.status(400).json({ success: false, data: {}, message: "Subject is required." }); + return next(new AppError(400, "Subject is required.")); } const hasBody = (typeof resolvedHtml === "string" && resolvedHtml.trim().length > 0) || (typeof resolvedText === "string" && resolvedText.trim().length > 0); if (!hasBody) { - return res.status(400).json({ success: false, data: {}, message: "Provide at least one of html or text content." }); + return next(new AppError(400, "Provide at least one of html or text content.")); } resolvedSubject = renderTemplateString(resolvedSubject, vars, { mode: "text" }); @@ -270,14 +262,14 @@ module.exports.sendMail = async (req, res) => { } if (!resolvedSubject || !resolvedSubject.trim()) { - return res.status(400).json({ success: false, data: {}, message: "Subject is required." }); + return next(new AppError(400, "Subject is required.")); } const hasRenderedBody = (typeof resolvedHtml === "string" && resolvedHtml.trim().length > 0) || (typeof resolvedText === "string" && resolvedText.trim().length > 0); if (!hasRenderedBody) { - return res.status(400).json({ success: false, data: {}, message: "Provide at least one of html or text content." }); + return next(new AppError(400, "Provide at least one of html or text content.")); } const encryptedByokKey = @@ -292,7 +284,7 @@ module.exports.sendMail = async (req, res) => { : process.env.RESEND_API_KEY_2 || process.env.RESEND_API_KEY; if (!clientKey) { - return res.status(500).json({ success: false, data: {}, message: "Resend API key is not configured." }); + return next(new AppError(500, "Resend API key is not configured.")); } const limit = getMonthlyMailLimit(req.project, req.planLimits); @@ -317,36 +309,25 @@ module.exports.sendMail = async (req, res) => { backoff: { type: 'exponential', delay: 5000 } }); - return res.status(200).json({ - success: true, - data: { - id: job.id ? String(job.id) : null, - provider: usingByok ? "byok" : "default", - monthlyUsage: count, - monthlyLimit: limit, - ...(templateUsed ? { templateUsed } : {}), - }, - message: "Mail queued successfully.", - }); + return new ApiResponse({ + id: job.id ? String(job.id) : null, + provider: usingByok ? "byok" : "default", + monthlyUsage: count, + monthlyLimit: limit, + ...(templateUsed ? { templateUsed } : {}), + }, "Mail queued successfully.").send(res, 200); } catch (err) { if (consumedQuotaKey) { await redis.decr(consumedQuotaKey).catch(() => {}); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid mail payload.", - }); - } - - return res.status(err.statusCode || 500).json({ - success: false, - data: {}, - message: err.message || "Failed to send mail.", - ...(typeof err.limit === "number" ? { limit: err.limit } : {}), - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid mail payload.", "Validation Error")); + } + + const appErr = new AppError(err.statusCode || 500, err.message || "Failed to send mail."); + if (typeof err.limit === "number") appErr.limit = err.limit; + return next(appErr); } }; @@ -391,11 +372,11 @@ const requireByokGate = async (req) => { }; // GET /api/mail/logs -module.exports.getMailLogs = async (req, res) => { +module.exports.getMailLogs = async (req, res, next) => { try { const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const parsedPage = parseInt(req.query.page, 10); @@ -411,63 +392,55 @@ module.exports.getMailLogs = async (req, res) => { .limit(limit) .lean(); - return res.status(200).json({ - success: true, - data: { - items: logs, - total, - page, - limit, - }, - message: "Mail logs retrieved successfully." - }); + return new ApiResponse({ + items: logs, + total, + page, + limit, + }, "Mail logs retrieved successfully.").send(res, 200); } catch (err) { - return res.status(500).json({ success: false, data: {}, message: err.message || "Failed to retrieve mail logs." }); + return next(new AppError(500, err.message || "Failed to retrieve mail logs.")); } }; // GET /api/mail/logs/:resendId -module.exports.getMailStatus = async (req, res) => { +module.exports.getMailStatus = async (req, res, next) => { try { const { resendId } = req.params; - if (!resendId) return res.status(400).json({ success: false, data: {}, message: "resendId is required." }); + if (!resendId) return next(new AppError(400, "resendId is required.")); if (!/^[A-Za-z0-9_-]{1,128}$/.test(resendId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid resendId format." }); + return next(new AppError(400, "Invalid resendId format.")); } const projectId = req.project?._id; const logEntry = await MailLog.findOne({ resendEmailId: resendId, projectId }).lean(); if (!logEntry) { - return res.status(404).json({ success: false, data: {}, message: "Mail log entry not found for this project." }); + return next(new AppError(404, "Mail log entry not found for this project.")); } const { resend } = await resolveResendClient(req); const { data, error } = await resend.emails.get(resendId); if (error) { - return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message || "Failed to fetch email status from Resend." }); + return next(new AppError(error.statusCode || 500, error.message || "Failed to fetch email status from Resend.")); } - return res.status(200).json({ - success: true, - data: { - dbLog: logEntry, - last_event: data?.last_event || logEntry.status, - resendStatus: data - }, - message: "Mail status retrieved successfully." - }); + return new ApiResponse({ + dbLog: logEntry, + last_event: data?.last_event || logEntry.status, + resendStatus: data + }, "Mail status retrieved successfully.").send(res, 200); } catch (err) { - return res.status(500).json({ success: false, data: {}, message: err.message || "Failed to fetch mail status." }); + return next(new AppError(500, err.message || "Failed to fetch mail status.")); } }; // POST /api/mail/webhook (No auth required) const { Webhook } = require("svix"); -module.exports.handleResendWebhook = async (req, res) => { +module.exports.handleResendWebhook = async (req, res, next) => { const secret = process.env.RESEND_WEBHOOK_SECRET; if (!secret) { - return res.status(200).json({ success: true, message: "Webhook ignored: secret not configured." }); + return new ApiResponse(null, "Webhook ignored: secret not configured.").send(res, 200); } const payload = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : (typeof req.body === 'string' ? req.body : JSON.stringify(req.body)); @@ -478,7 +451,7 @@ module.exports.handleResendWebhook = async (req, res) => { try { evt = wh.verify(payload, headers); } catch (err) { - return res.status(400).json({ success: false, message: "Webhook signature verification failed." }); + return next(new AppError(400, "Webhook signature verification failed.")); } const { type, data } = evt; @@ -498,7 +471,7 @@ module.exports.handleResendWebhook = async (req, res) => { } } - return res.status(200).json({ success: true }); + return new ApiResponse(null).send(res, 200); }; // POST /api/mail/send-batch @@ -511,21 +484,17 @@ const sendBatchSchema = z.array( }) ).min(1).max(100); -module.exports.sendBatchMail = async (req, res) => { +module.exports.sendBatchMail = async (req, res, next) => { const reservedKeys = []; try { if (req.keyRole !== "secret") { - return res.status(403).json({ - success: false, - data: {}, - message: "Forbidden. This action requires a Secret Key (sk_live_...).", - }); + return next(new AppError(403, "Forbidden. This action requires a Secret Key (sk_live_...).")); } const batch = sendBatchSchema.parse(req.body); const projectId = req.project?._id; if (!projectId) { - return res.status(401).json({ success: false, data: {}, message: "Project context missing." }); + return next(new AppError(401, "Project context missing.")); } const { resend, usingByok, fromAddress } = await resolveResendClient(req); @@ -549,7 +518,7 @@ module.exports.sendBatchMail = async (req, res) => { for (const k of reservedKeys) { await redis.decr(k).catch(() => {}); } - return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message || "Batch send failed." }); + return next(new AppError(error.statusCode || 500, error.message || "Batch send failed.")); } const results = data?.data || data || []; @@ -570,105 +539,94 @@ module.exports.sendBatchMail = async (req, res) => { await MailLog.insertMany(logDocs).catch(e => console.error("Batch log insertion error:", e)); } - return res.status(200).json({ - success: true, - data: results, - message: `Successfully dispatched batch of ${results.length} emails.` - }); + return new ApiResponse(results, `Successfully dispatched batch of ${results.length} emails.`).send(res, 200); } catch (err) { for (const k of reservedKeys) { await redis.decr(k).catch(() => {}); } if (err instanceof z.ZodError) { - return res.status(400).json({ - success: false, - data: {}, - message: err.issues?.[0]?.message || "Invalid batch mail payload.", - }); - } - - return res.status(err.statusCode || 500).json({ - success: false, - data: {}, - message: err.message || "Failed to send batch mail.", - ...(typeof err.limit === "number" ? { limit: err.limit } : {}), - }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid batch mail payload.", "Validation Error")); + } + + const appErr = new AppError(err.statusCode || 500, err.message || "Failed to send batch mail."); + if (typeof err.limit === "number") appErr.limit = err.limit; + return next(appErr); } }; // --- AUDIENCES (BYOK Gate) --- -module.exports.createAudience = async (req, res) => { +module.exports.createAudience = async (req, res, next) => { try { const resend = await requireByokGate(req); const { name } = req.body; - if (!name) return res.status(400).json({ success: false, data: {}, message: "Audience name is required." }); + if (!name) return next(new AppError(400, "Audience name is required.")); const { data, error } = await resend.audiences.create({ name }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getAudiences = async (req, res) => { +module.exports.getAudiences = async (req, res, next) => { try { const resend = await requireByokGate(req); const { data, error } = await resend.audiences.list(); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getAudienceById = async (req, res) => { +module.exports.getAudienceById = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.audiences.get(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteAudience = async (req, res) => { +module.exports.deleteAudience = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.audiences.remove(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; // --- CONTACTS (BYOK Gate) --- -module.exports.addContact = async (req, res) => { +module.exports.addContact = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { email, firstName, lastName, unsubscribed } = req.body; - if (!email) return res.status(400).json({ success: false, data: {}, message: "Contact email is required." }); + if (!email) return next(new AppError(400, "Contact email is required.")); const payload = { audienceId: id, email }; if (firstName) payload.firstName = firstName; @@ -676,51 +634,51 @@ module.exports.addContact = async (req, res) => { if (unsubscribed !== undefined) payload.unsubscribed = unsubscribed; const { data, error } = await resend.contacts.create(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getContacts = async (req, res) => { +module.exports.getContacts = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience ID format." }); + return next(new AppError(400, "Invalid audience ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.list({ audienceId: id }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getContactById = async (req, res) => { +module.exports.getContactById = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.get({ audienceId: id, id: contactId }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.updateContact = async (req, res) => { +module.exports.updateContact = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { firstName, lastName, unsubscribed } = req.body; @@ -731,27 +689,27 @@ module.exports.updateContact = async (req, res) => { if (unsubscribed !== undefined) payload.unsubscribed = unsubscribed; const { data, error } = await resend.contacts.update(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteContact = async (req, res) => { +module.exports.deleteContact = async (req, res, next) => { try { const { id, contactId } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id) || !/^[A-Za-z0-9_-]+$/.test(contactId)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid audience or contact ID format." }); + return next(new AppError(400, "Invalid audience or contact ID format.")); } const resend = await requireByokGate(req); const { data, error } = await resend.contacts.remove({ audienceId: id, id: contactId }); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; @@ -767,13 +725,13 @@ const requireBroadcastGate = async (req) => { return resend; }; -module.exports.createBroadcast = async (req, res) => { +module.exports.createBroadcast = async (req, res, next) => { try { const resend = await requireBroadcastGate(req); const { audienceId, segmentId, from, subject, html, scheduledAt } = req.body; const resolvedAudienceId = audienceId || segmentId; if (!resolvedAudienceId || !subject || !html) { - return res.status(400).json({ success: false, data: {}, message: "audienceId, subject, and html are required." }); + return next(new AppError(400, "audienceId, subject, and html are required.")); } const payload = { @@ -785,70 +743,70 @@ module.exports.createBroadcast = async (req, res) => { if (scheduledAt) payload.scheduledAt = scheduledAt; const { data, error } = await resend.broadcasts.create(payload); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.sendBroadcast = async (req, res) => { +module.exports.sendBroadcast = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.send(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getBroadcasts = async (req, res) => { +module.exports.getBroadcasts = async (req, res, next) => { try { const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.list(); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.getBroadcastById = async (req, res) => { +module.exports.getBroadcastById = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.get(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; -module.exports.deleteBroadcast = async (req, res) => { +module.exports.deleteBroadcast = async (req, res, next) => { try { const { id } = req.params; if (!/^[A-Za-z0-9_-]+$/.test(id)) { - return res.status(400).json({ success: false, data: {}, message: "Invalid broadcast ID format." }); + return next(new AppError(400, "Invalid broadcast ID format.")); } const resend = await requireBroadcastGate(req); const { data, error } = await resend.broadcasts.remove(id); - if (error) return res.status(error.statusCode || 500).json({ success: false, data: {}, message: error.message }); + if (error) return next(new AppError(error.statusCode || 500, error.message)); - return res.status(200).json({ success: true, data }); + return new ApiResponse(data).send(res, 200); } catch (err) { - return res.status(err.statusCode || 500).json({ success: false, data: {}, message: err.message }); + return next(new AppError(err.statusCode || 500, err.message)); } }; diff --git a/apps/public-api/src/controllers/schema.controller.js b/apps/public-api/src/controllers/schema.controller.js index a8f7c0473..e927f7027 100644 --- a/apps/public-api/src/controllers/schema.controller.js +++ b/apps/public-api/src/controllers/schema.controller.js @@ -9,6 +9,8 @@ const { clearCompiledModel, createUniqueIndexes, generateApiKey, + AppError, + ApiResponse, } = require("@urbackend/common"); const { z } = require("zod"); @@ -26,13 +28,13 @@ const dropCollectionIfExists = async (connection, collectionName) => { } }; -module.exports.checkSchema = async (req, res) => { +module.exports.checkSchema = async (req, res, next) => { try { const { collectionName } = req.params; const project = req.project; if (!project) { - return res.status(401).json({ error: "Project missing from request." }); + return next(new AppError(401, "Project missing from request.")); } const collectionConfig = project.collections.find( @@ -40,19 +42,17 @@ module.exports.checkSchema = async (req, res) => { ); if (!collectionConfig) { - return res.status(404).json({ error: "Schema/Collection not found" }); + return next(new AppError(404, "Schema/Collection not found")); } - res - .status(200) - .json({ message: "Schema exists", collection: collectionConfig }); + return new ApiResponse({ collection: collectionConfig }, "Schema exists").send(res, 200); } catch (err) { console.error(err); - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; -module.exports.createSchema = async (req, res) => { +module.exports.createSchema = async (req, res, next) => { let fullProject; let connection; let compiledCollectionName; @@ -65,20 +65,18 @@ module.exports.createSchema = async (req, res) => { collectionNameForRollback = name; const project = req.project; if (!project) { - return res.status(401).json({ error: "Project missing from request." }); + return next(new AppError(401, "Project missing from request.")); } const projectId = project._id; fullProject = await Project.findById(projectId); if (!fullProject) - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); const exists = fullProject.collections.find((c) => c.name === name); if (exists) - return res - .status(400) - .json({ error: "Collection/Schema already exists" }); + return next(new AppError(400, "Collection/Schema already exists")); if (!fullProject.jwtSecret) { fullProject.jwtSecret = generateApiKey("jwt_"); @@ -179,9 +177,7 @@ module.exports.createSchema = async (req, res) => { delete projectObj.secretKey; delete projectObj.jwtSecret; - return res - .status(201) - .json({ message: "Schema created successfully", project: projectObj }); + return new ApiResponse({ project: projectObj }, "Schema created successfully").send(res, 201); } catch (err) { try { if (fullProject && collectionWasPersisted) { @@ -203,10 +199,10 @@ module.exports.createSchema = async (req, res) => { } if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues }); + return next(new AppError(400, err.issues?.[0]?.message || "Invalid schema payload.")); } console.error(err); - return res.status(400).json({ error: err.message }); + return next(new AppError(400, err.message)); } }; diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index cd24b108f..ef9177ab6 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -1,4 +1,4 @@ -const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic } = require("@urbackend/common"); +const { getStorage, getPresignedUploadUrl, verifyUploadedFile, Project, isProjectStorageExternal, getBucket, redis, getMonthKey, getEndOfMonthTtlSeconds, incrWithTtlAtomic, AppError, ApiResponse } = require("@urbackend/common"); const { randomUUID } = require("crypto"); const path = require("path"); @@ -79,15 +79,15 @@ const bestEffortDeleteUploadedObject = async (project, filePath) => { // Upload File -module.exports.uploadFile = async (req, res) => { +module.exports.uploadFile = async (req, res, next) => { try { const file = req.file; if (!file) { - return res.status(400).json({ error: "No file uploaded." }); + return next(new AppError(400, "No file uploaded.")); } if (file.size > MAX_FILE_SIZE) { - return res.status(413).json({ error: "File size exceeds limit." }); + return next(new AppError(413, "File size exceeds limit.")); } const project = req.project; @@ -110,7 +110,7 @@ module.exports.uploadFile = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + return next(new AppError(403, "Storage limit exceeded. Please upgrade your plan or delete some files.")); } } else { const result = await Project.updateOne( @@ -122,7 +122,7 @@ module.exports.uploadFile = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(403).json({ error: "Storage limit exceeded. Please upgrade your plan or delete some files." }); + return next(new AppError(403, "Storage limit exceeded. Please upgrade your plan or delete some files.")); } } } @@ -159,31 +159,24 @@ module.exports.uploadFile = async (req, res) => { updateMonthlyUsageCounter(project._id, "storage:uploadedBytes", file.size); - return res.status(201).json({ - message: "File uploaded successfully", + return new ApiResponse({ url: publicUrlData.publicUrl, path: filePath, provider: external ? "external" : "internal" - }); + }, "File uploaded successfully").send(res, 201); } catch (err) { - return res.status(500).json({ - error: "File upload failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File upload failed") : "File upload failed")); } }; /** * Delete File */ -module.exports.deleteFile = async (req, res) => { +module.exports.deleteFile = async (req, res, next) => { try { const { path } = req.body; if (!path) { - return res.status(400).json({ error: "File path is required." }); + return next(new AppError(400, "File path is required.")); } const project = req.project; @@ -192,7 +185,7 @@ module.exports.deleteFile = async (req, res) => { const normalizedPath = normalizeProjectPath(project._id, path); if (!normalizedPath) { - return res.status(403).json({ error: "Access denied." }); + return next(new AppError(403, "Access denied.")); } const supabase = await getStorage(project); @@ -232,23 +225,17 @@ module.exports.deleteFile = async (req, res) => { updateMonthlyUsageCounter(project._id, "storage:deletedBytes", fileSize); - return res.json({ message: "File deleted successfully" }); + return new ApiResponse(null, "File deleted successfully").send(res, 200); } catch (err) { - return res.status(500).json({ - error: "File deletion failed", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File deletion failed") : "File deletion failed")); } }; -module.exports.deleteAllFiles = async (req, res) => { +module.exports.deleteAllFiles = async (req, res, next) => { try { const project = req.project; // assuming middleware attaches project if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const supabase = await getStorage(project); @@ -288,35 +275,28 @@ module.exports.deleteAllFiles = async (req, res) => { ); } - res.json({ - success: true, + return new ApiResponse({ deleted: deletedCount, provider: isProjectStorageExternal(project) ? "external" : "internal" - }); + }).send(res, 200); } catch (err) { - res.status(500).json({ - error: "Failed to delete files", - details: - process.env.NODE_ENV === "development" - ? err.message - : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Failed to delete files") : "Failed to delete files")); } }; // REQUEST UPLOAD - generates presigned URL for direct browser upload -module.exports.requestUpload = async (req, res) => { +module.exports.requestUpload = async (req, res, next) => { try { const { filename, contentType, size } = req.body; const numericSize = parsePositiveSize(size); if (!filename || !contentType || numericSize === null) - return res.status(400).json({ error: "filename, contentType, and size are required." }); + return next(new AppError(400, "filename, contentType, and size are required.")); if (numericSize > MAX_FILE_SIZE) - return res.status(413).json({ error: "File size exceeds limit." }); + return next(new AppError(413, "File size exceeds limit.")); const project = req.project; const external = isProjectStorageExternal(project); @@ -326,7 +306,7 @@ module.exports.requestUpload = async (req, res) => { if (!external) { const quotaLimit = effectiveLimit === -1 ? SAFETY_MAX_BYTES : effectiveLimit; if (project.storageUsed + numericSize > quotaLimit) - return res.status(403).json({ error: "Internal storage limit exceeded." }); + return next(new AppError(403, "Internal storage limit exceeded.")); } const safeName = filename @@ -337,23 +317,20 @@ module.exports.requestUpload = async (req, res) => { const { signedUrl, token } = await getPresignedUploadUrl(project, filePath, contentType, numericSize); - return res.status(200).json({ signedUrl, token, filePath }); + return new ApiResponse({ signedUrl, token, filePath }).send(res, 200); } catch (err) { - return res.status(500).json({ - error: "Could not generate upload URL", - details: process.env.NODE_ENV === "development" ? err.message : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Could not generate upload URL") : "Could not generate upload URL")); } }; // CONFIRM UPLOAD - verifies file landed on cloud, then charges quota -module.exports.confirmUpload = async (req, res) => { +module.exports.confirmUpload = async (req, res, next) => { try { const { filePath, size } = req.body; const declaredSize = parsePositiveSize(size); if (!filePath || declaredSize === null) - return res.status(400).json({ error: "filePath and size are required." }); + return next(new AppError(400, "filePath and size are required.")); const project = req.project; const external = isProjectStorageExternal(project); @@ -361,7 +338,7 @@ module.exports.confirmUpload = async (req, res) => { // make sure client isn't confirming someone else's file if (!normalizedPath) - return res.status(403).json({ error: "Access denied." }); + return next(new AppError(403, "Access denied.")); // verify file actually exists on cloud before touching quota let actualSize; @@ -370,22 +347,19 @@ module.exports.confirmUpload = async (req, res) => { } catch (err) { if (err?.message === "File not found after upload") { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(409).json({ - error: "UPLOAD_NOT_READY", - message: "Uploaded file is not visible yet. Please retry confirmation." - }); + return next(new AppError(409, "Uploaded file is not visible yet. Please retry confirmation.", "UPLOAD_NOT_READY")); } throw err; } if (!Number.isFinite(actualSize) || actualSize <= 0) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(500).json({ error: "Upload confirmation failed", details: process.env.NODE_ENV === "development" ? "Uploaded file size could not be determined" : undefined }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? "Uploaded file size could not be determined" : "Upload confirmation failed")); } if (Math.abs(actualSize - declaredSize) > CONFIRM_UPLOAD_SIZE_TOLERANCE_BYTES) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(400).json({ error: "Declared file size does not match uploaded file size." }); + return next(new AppError(400, "Declared file size does not match uploaded file size.")); } // now it's safe to charge quota @@ -402,7 +376,7 @@ module.exports.confirmUpload = async (req, res) => { ); if (result.matchedCount === 0) { await bestEffortDeleteUploadedObject(project, normalizedPath); - return res.status(403).json({ error: "Internal storage limit exceeded." }); + return next(new AppError(403, "Internal storage limit exceeded.")); } } @@ -425,11 +399,8 @@ module.exports.confirmUpload = async (req, res) => { response.warning = publicUrlData?.error || "Upload confirmed, but a public URL is unavailable."; } - return res.status(200).json(response); + return new ApiResponse(response, response.message).send(res, 200); } catch (err) { - return res.status(500).json({ - error: "Upload confirmation failed", - details: process.env.NODE_ENV === "development" ? err.message : undefined - }); + return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Upload confirmation failed") : "Upload confirmation failed")); } }; diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index 217a0708c..a2fff92ba 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -7,7 +7,7 @@ const {redis} = require('@urbackend/common'); const {Project} = require('@urbackend/common'); const { authEmailQueue } = require('@urbackend/common'); const { checkLockout, recordFailedAttempt, clearLockout } = require('@urbackend/common'); -const { AppError } = require('@urbackend/common'); +const { AppError, ApiResponse } = require('@urbackend/common'); const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common'); const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); const { getConnection } = require('@urbackend/common'); @@ -740,11 +740,11 @@ const sanitizePublicProfile = (userDoc, usersColConfig) => { * Requires x-api-key header or ?key query param and auth enabled on the project. * @route GET /api/userAuth/social/:provider/start */ -module.exports.startSocialAuth = async (req, res) => { +module.exports.startSocialAuth = async (req, res, next) => { try { const provider = String(req.params.provider || '').trim().toLowerCase(); if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { - return res.status(404).json({ error: 'Unsupported social auth provider' }); + return next(new AppError(404, 'Unsupported social auth provider')); } assertAuthProjectReady(req.project); @@ -788,7 +788,7 @@ module.exports.startSocialAuth = async (req, res) => { * issues auth tokens, and redirects to the frontend callback URL. * @route GET /api/userAuth/social/:provider/callback */ -module.exports.handleSocialAuthCallback = async (req, res) => { +module.exports.handleSocialAuthCallback = async (req, res, next) => { // Helper to redirect error to frontend instead of returning JSON const redirectWithError = (callbackUrl, errorMessage) => { try { @@ -797,7 +797,7 @@ module.exports.handleSocialAuthCallback = async (req, res) => { return res.redirect(url.toString()); } catch { // Fallback if URL is malformed - return res.status(400).json({ error: errorMessage }); + return next(new AppError(400, errorMessage)); } }; @@ -832,30 +832,30 @@ module.exports.handleSocialAuthCallback = async (req, res) => { return redirectWithError(callbackUrl, errorMsg); } // No callback URL available - return JSON as fallback - return res.status(400).json({ error: errorMsg }); + return next(new AppError(400, errorMsg)); } try { const provider = String(req.params.provider || '').trim().toLowerCase(); if (!SOCIAL_PROVIDER_KEYS.includes(provider)) { - return res.status(404).json({ error: 'Unsupported social auth provider' }); + return next(new AppError(404, 'Unsupported social auth provider')); } const code = String(req.query.code || '').trim(); if (!code || !state) { const errorMsg = 'Missing code or state'; if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(400).json({ error: errorMsg }); + return next(new AppError(400, errorMsg)); } if (!parsedState) { - return res.status(400).json({ error: 'Invalid or expired OAuth state' }); + return next(new AppError(400, 'Invalid or expired OAuth state')); } if (parsedState.provider !== provider || !parsedState.projectId) { const errorMsg = 'OAuth state mismatch'; if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(400).json({ error: errorMsg }); + return next(new AppError(400, errorMsg)); } // Update callbackUrl from parsed state @@ -865,7 +865,7 @@ module.exports.handleSocialAuthCallback = async (req, res) => { if (!project || !providerConfig) { const errorMsg = `${provider} social auth is disabled or incomplete for this project.`; if (callbackUrl) return redirectWithError(callbackUrl, errorMsg); - return res.status(422).json({ error: 'Provider not configured', message: errorMsg }); + return next(new AppError(422, 'Provider not configured: ' + errorMsg)); } // Use project's callback URL @@ -938,61 +938,37 @@ module.exports.handleSocialAuthCallback = async (req, res) => { * Completes the OAuth token flow initiated by handleSocialAuthCallback. * @route POST /api/userAuth/social/exchange */ -module.exports.exchangeSocialRefreshToken = async (req, res) => { +module.exports.exchangeSocialRefreshToken = async (req, res, next) => { try { const rtCode = String(req.body?.rtCode || '').trim(); const token = String(req.body?.token || '').trim(); if (!rtCode || !token) { - return res.status(400).json({ - success: false, - data: {}, - message: 'rtCode and token are required', - }); + return next(new AppError(400, 'rtCode and token are required',)); } const exchangeKey = getSocialRefreshExchangeKey(rtCode); const rawExchange = await redis.getdel(exchangeKey); if (!rawExchange) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); + return next(new AppError(400, 'Invalid or expired refresh token exchange code',)); } let parsedExchange; try { parsedExchange = JSON.parse(rawExchange); } catch (err) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Invalid or expired refresh token exchange code', - }); + return next(new AppError(400, 'Invalid or expired refresh token exchange code',)); } if (parsedExchange.token !== token || !parsedExchange.refreshToken) { - return res.status(403).json({ - success: false, - data: {}, - message: 'Invalid refresh token exchange payload', - }); + return next(new AppError(403, 'Invalid refresh token exchange payload',)); } - return res.status(200).json({ - success: true, - data: { + return new ApiResponse({ refreshToken: parsedExchange.refreshToken, - }, - message: 'Refresh token exchanged successfully', - }); + }, 'Refresh token exchanged successfully',).send(res, 200); } catch (err) { - return res.status(500).json({ - success: false, - data: {}, - message: 'Internal server error', - }); + return next(new AppError(500, 'Internal server error',)); } }; @@ -1002,7 +978,7 @@ module.exports.exchangeSocialRefreshToken = async (req, res) => { * Hashes the password and triggers a verification email if mandatory. * @route POST /api/userAuth/signup */ -module.exports.signup = async (req, res) => { +module.exports.signup = async (req, res, next) => { try { const project = req.project; @@ -1011,7 +987,7 @@ module.exports.signup = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1020,7 +996,7 @@ module.exports.signup = async (req, res) => { if (existingUser) { const deletedMsg = checkUserSoftDeleted(existingUser); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); // Check if user is unverified. If so, we can trigger a resend instead of a hard error. const verificationField = getVerificationField(usersColConfig); @@ -1052,8 +1028,8 @@ module.exports.signup = async (req, res) => { userId: existingUser._id }); } - - return res.status(400).json({ error: "User already exists with this email." }); + console.log('ABOUT TO CALL NEXT FOR EXISTING USER'); + return next(new AppError(400, "User already exists with this email.")); } const salt = await bcrypt.genSalt(10); @@ -1108,9 +1084,9 @@ module.exports.signup = async (req, res) => { } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); console.log(err) } } @@ -1150,7 +1126,7 @@ module.exports.login = async (req, res, next) => { } const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1214,8 +1190,8 @@ module.exports.login = async (req, res, next) => { }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); // Fixed: .json() + if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); + return next(new AppError(500, err.message)); // Fixed: .json() } } @@ -1223,19 +1199,19 @@ module.exports.login = async (req, res, next) => { * Returns the currently authenticated user's profile based on the JWT token. * @route GET /api/userAuth/me */ -module.exports.me = async (req, res) => { +module.exports.me = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); try { const decoded = jwt.verify(token, project.jwtSecret); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1245,52 +1221,53 @@ module.exports.me = async (req, res) => { { password: 0 } ).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); res.json(user); } catch (err) { - return res.status(401).json({ error: "Invalid or Expired Token" }); + return next(new AppError(401, "Invalid or Expired Token")); } } catch (err) { - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } } // GET PUBLIC PROFILE BY USERNAME -module.exports.publicProfile = async (req, res) => { +module.exports.publicProfile = async (req, res, next) => { try { const project = req.project; const username = String(req.params.username || '').trim(); - if (!username) return res.status(400).json({ error: "Username is required" }); + if (!username) return next(new AppError(400, "Username is required")); const { usersColConfig, Model } = await getUsersModel(project); - if (!usersColConfig || !Model) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig || !Model) return next(new AppError(404, "Auth collection not found")); const hasUsernameField = (usersColConfig.model || []).some((f) => String(f?.key || '').trim() === 'username'); if (!hasUsernameField) { - return res.status(400).json({ error: "Public profile requires a 'username' field in users schema" }); + return next(new AppError(400, "Public profile requires a 'username' field in users schema")); } const user = await Model.findOne({ username }, { password: 0 }).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); const profile = sanitizePublicProfile(user, usersColConfig); - return res.json(profile); + return new ApiResponse(profile, 'Public profile retrieved successfully').send(res, 200); } catch (err) { - return res.status(500).json({ error: err.message }); + console.error("publicProfile ERROR:", err); + return next(new AppError(500, err.message)); } } // POST REQ FOR ADMIN CREATE USER -module.exports.createAdminUser = async (req, res) => { +module.exports.createAdminUser = async (req, res, next) => { try { const project = req.project; @@ -1299,14 +1276,14 @@ module.exports.createAdminUser = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const existingUser = await Model.findOne({ email }); if (existingUser) { - return res.status(400).json({ error: "User already exists with this email." }); + return next(new AppError(400, "User already exists with this email.")); } const salt = await bcrypt.genSalt(10); @@ -1329,14 +1306,14 @@ module.exports.createAdminUser = async (req, res) => { } catch (err) { if (err instanceof z.ZodError) { console.error(err); - return res.status(400).json({ error: err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed" }); + return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); } - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } } // PATCH REQ FOR ADMIN RESET PASSWORD -module.exports.resetPassword = async (req, res) => { +module.exports.resetPassword = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -1344,11 +1321,11 @@ module.exports.resetPassword = async (req, res) => { const { newPassword } = req.body; if (!newPassword || newPassword.length < 6) { - return res.status(400).json({ error: "Password must be at least 6 characters" }); + return next(new AppError(400, "Password must be at least 6 characters")); } const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1362,18 +1339,18 @@ module.exports.resetPassword = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); + return next(new AppError(404, "User not found")); } res.json({ message: "Password updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } } // POST REQ FOR EMAIL VERIFICATION -module.exports.verifyEmail = async (req, res) => { +module.exports.verifyEmail = async (req, res, next) => { try { const project = req.project; const { email, otp } = verifyOtpSchema.parse(req.body); @@ -1384,56 +1361,56 @@ module.exports.verifyEmail = async (req, res) => { if (!storedOtp || storedOtp !== otp) { - return res.status(400).json({ error: "Invalid or expired OTP" }); + return next(new AppError(400, "Invalid or expired OTP")); } const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const verificationField = getVerificationField(usersColConfig); if (!verificationField) { - return res.status(500).json({ error: "No verification field found in users schema" }); + return next(new AppError(500, "No verification field found in users schema")); } const result = await Model.updateOne( { email }, { $set: { [verificationField]: true } } ); - if (result.matchedCount === 0) return res.status(404).json({ error: "User not found" }); + if (result.matchedCount === 0) return next(new AppError(404, "User not found")); await redis.del(redisKey); res.json({ message: "Email verified successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); } }; // RESEND VERIFICATION OTP -module.exports.resendVerificationOtp = async (req, res) => { +module.exports.resendVerificationOtp = async (req, res, next) => { try { const project = req.project; const { email } = onlyEmailSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); const { usersColConfig, Model } = await getUsersModel(project); - if (!Model) return res.status(404).json({ error: "Auth collection not found" }); + if (!Model) return next(new AppError(404, "Auth collection not found")); const user = await Model.findOne({ email: normalizedEmail }); if (!user) { - return res.status(400).json({ error: "User not found with this email." }); + return next(new AppError(400, "User not found with this email.")); } const verificationField = getVerificationField(usersColConfig); const isVerified = verificationField ? !!user[verificationField] : true; if (isVerified) { - return res.status(400).json({ error: "Email is already verified." }); + return next(new AppError(400, "Email is already verified.")); } // Check cooldown @@ -1458,21 +1435,21 @@ module.exports.resendVerificationOtp = async (req, res) => { res.json({ message: "Verification code sent successfully." }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); + return next(new AppError(500, err.message)); } }; // POST REQ FOR PASSWORD RESET REQUEST -module.exports.requestPasswordReset = async (req, res) => { +module.exports.requestPasswordReset = async (req, res, next) => { try { const project = req.project; const { email } = onlyEmailSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1480,14 +1457,14 @@ module.exports.requestPasswordReset = async (req, res) => { try { await checkPublicOtpCooldown(project._id, normalizedEmail, 'reset'); } catch (cooldownErr) { - return res.status(cooldownErr.statusCode || 429).json({ success: false, data: {}, message: cooldownErr.message }); + return next(new AppError(cooldownErr.statusCode || 429, cooldownErr.message)); } const user = await Model.findOne({ email: normalizedEmail }); if (!user || (user && user.isDeleted)) { await setPublicOtpCooldown(project._id, normalizedEmail, 'reset'); - return res.json({ message: "If that email exists, a reset code has been sent." }); + return new ApiResponse(null, "If that email exists, a reset code has been sent.").send(res, 200); } const otp = crypto.randomInt(100000, 1000000).toString(); @@ -1498,13 +1475,13 @@ module.exports.requestPasswordReset = async (req, res) => { res.json({ message: "If that email exists, a reset code has been sent." }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); } }; // POST REQ FOR PASSWORD RESET CONFIRMATION -module.exports.resetPasswordUser = async (req, res) => { +module.exports.resetPasswordUser = async (req, res, next) => { try { const project = req.project; const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); @@ -1514,20 +1491,20 @@ module.exports.resetPasswordUser = async (req, res) => { const storedOtp = await redis.get(redisKey); if (!storedOtp || storedOtp !== otp) { - return res.status(400).json({ error: "Invalid or expired OTP" }); + return next(new AppError(400, "Invalid or expired OTP")); } const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); const { Model: collection } = await getUsersModel(project); - if (!collection) return res.status(404).json({ error: "Auth collection not found" }); + if (!collection) return next(new AppError(404, "Auth collection not found")); const user = await collection.findOne({ email: normalizedEmail }); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); await collection.updateOne( { email: normalizedEmail }, @@ -1545,25 +1522,25 @@ module.exports.resetPasswordUser = async (req, res) => { res.json({ message: "Password updated successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); } }; // PATCH REQ FOR UPDATE PROFILE -module.exports.updateProfile = async (req, res) => { +module.exports.updateProfile = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); let decoded; try { decoded = jwt.verify(token, project.jwtSecret); } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + return next(new AppError(401, "Access Denied: Invalid or expired token")); } const usersColConfig = project.collections.find( @@ -1571,9 +1548,7 @@ module.exports.updateProfile = async (req, res) => { ); if (!usersColConfig) { - return res.status(404).json({ - error: "Auth collection not found" - }); + return next(new AppError(404, "Auth collection not found")); } const restrictedFields = [ '_id', @@ -1599,7 +1574,7 @@ module.exports.updateProfile = async (req, res) => { if (updateData.username !== undefined) { const username = updateData.username; if (typeof username !== 'string' || username.length < 3 || username.length > 50) { - return res.status(400).json({ error: "Username must be between 3 and 50 characters." }); + return next(new AppError(400, "Username must be between 3 and 50 characters.")); } } @@ -1609,10 +1584,10 @@ module.exports.updateProfile = async (req, res) => { const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); await Model.updateOne( { _id: new mongoose.Types.ObjectId(decoded.userId) }, @@ -1623,42 +1598,42 @@ module.exports.updateProfile = async (req, res) => { res.json({ message: "Profile updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; // POST REQ FOR CHANGE PASSWORD -module.exports.changePasswordUser = async (req, res) => { +module.exports.changePasswordUser = async (req, res, next) => { try { const project = req.project; const tokenHeader = req.header('Authorization'); - if (!tokenHeader) return res.status(401).json({ error: "Access Denied: No Token Provided" }); + if (!tokenHeader) return next(new AppError(401, "Access Denied: No Token Provided")); const token = tokenHeader.replace("Bearer ", ""); let decoded; try { decoded = jwt.verify(token, project.jwtSecret); } catch (err) { - return res.status(401).json({ error: "Access Denied: Invalid or expired token" }); + return next(new AppError(401, "Access Denied: Invalid or expired token")); } const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); const user = await Model.findOne({ _id: new mongoose.Types.ObjectId(decoded.userId) }); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const deletedMsg = checkUserSoftDeleted(user); - if (deletedMsg) return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + if (deletedMsg) return next(new AppError(403, deletedMsg)); const validPass = await bcrypt.compare(currentPassword, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid current password" }); + if (!validPass) return next(new AppError(400, "Invalid current password")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); @@ -1671,45 +1646,45 @@ module.exports.changePasswordUser = async (req, res) => { res.json({ message: "Password changed successfully" }); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.issues?.[0]?.message || "Validation failed" }); - res.status(500).json({ error: err.message }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + return next(new AppError(500, err.message)); } }; -module.exports.refreshToken = async (req, res) => { +module.exports.refreshToken = async (req, res, next) => { try { const rawRefreshToken = readRefreshTokenFromRequest(req); if (!rawRefreshToken) { clearRefreshCookie(res); - return res.status(401).json({ error: 'Refresh token missing' }); + return next(new AppError(401, 'Refresh token missing')); } const parsedToken = parseRefreshToken(rawRefreshToken); if (!parsedToken) { clearRefreshCookie(res); - return res.status(401).json({ error: 'Invalid refresh token format' }); + return next(new AppError(401, 'Invalid refresh token format')); } const earlyRateLimit = await assertRefreshRateLimits({ req, tokenId: parsedToken.tokenId }); if (earlyRateLimit.limited) { clearRefreshCookie(res); - return res.status(429).json({ error: earlyRateLimit.message }); + return next(new AppError(429, earlyRateLimit.message)); } const session = await getRefreshSession(parsedToken.tokenId); if (!session) { clearRefreshCookie(res); - return res.status(401).json({ error: 'Refresh session not found' }); + return next(new AppError(401, 'Refresh session not found')); } if (String(req.project._id) !== String(session.projectId)) { - return res.status(403).json({ error: 'Refresh token does not belong to this project' }); + return next(new AppError(403, 'Refresh token does not belong to this project')); } const rateResult = await assertRefreshRateLimits({ req, tokenId: session.tokenId, userId: session.userId }); if (rateResult.limited) { clearRefreshCookie(res); - return res.status(429).json({ error: rateResult.message }); + return next(new AppError(429, rateResult.message)); } const now = Date.now(); @@ -1717,13 +1692,13 @@ module.exports.refreshToken = async (req, res) => { if (session.revokedAt || session.isUsed || isExpired) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(403).json({ error: 'Refresh token is invalid or already used' }); + return next(new AppError(403, 'Refresh token is invalid or already used')); } if (hashRefreshToken(rawRefreshToken) !== session.tokenHash) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(403).json({ error: 'Refresh token mismatch' }); + return next(new AppError(403, 'Refresh token mismatch')); } session.isUsed = true; @@ -1737,14 +1712,14 @@ module.exports.refreshToken = async (req, res) => { if (!project || !project.isAuthEnabled) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(401).json({ error: 'Project auth is unavailable' }); + return next(new AppError(401, 'Project auth is unavailable')); } const { Model } = await getUsersModel(project); if (!Model) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(404).json({ error: 'Auth collection not found' }); + return next(new AppError(404, 'Auth collection not found')); } const user = await Model.findOne( @@ -1754,14 +1729,14 @@ module.exports.refreshToken = async (req, res) => { if (!user) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(401).json({ success: false, data: {}, message: 'User not found for refresh token' }); + return next(new AppError(401, 'User not found for refresh token')); } const deletedMsg = checkUserSoftDeleted(user); if (deletedMsg) { await revokeSessionChain(session.tokenId); clearRefreshCookie(res); - return res.status(403).json({ success: false, data: {}, message: deletedMsg }); + return next(new AppError(403, deletedMsg)); } const newTokens = await issueAuthTokens({ @@ -1785,11 +1760,11 @@ module.exports.refreshToken = async (req, res) => { }); } catch (err) { clearRefreshCookie(res); - return res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; -module.exports.logout = async (req, res) => { +module.exports.logout = async (req, res, next) => { try { const rawRefreshToken = readRefreshTokenFromRequest(req); if (rawRefreshToken) { @@ -1798,7 +1773,7 @@ module.exports.logout = async (req, res) => { const session = await getRefreshSession(parsedToken.tokenId); if (session && hashRefreshToken(rawRefreshToken) === session.tokenHash) { if (String(req.project._id) !== String(session.projectId)) { - return res.status(403).json({ error: 'Refresh token does not belong to this project' }); + return next(new AppError(403, 'Refresh token does not belong to this project')); } session.revokedAt = new Date().toISOString(); session.isUsed = true; @@ -1812,18 +1787,18 @@ module.exports.logout = async (req, res) => { return res.status(200).json({ success: true, message: 'Logged out successfully' }); } catch (err) { clearRefreshCookie(res); - return res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; // FUNCTION - GET USER DETAILS (ADMIN) -module.exports.getUserDetails = async (req, res) => { +module.exports.getUserDetails = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1832,16 +1807,16 @@ module.exports.getUserDetails = async (req, res) => { { _id: new mongoose.Types.ObjectId(userId) }, { password: 0 } ).lean(); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); res.json(user); } catch (err) { - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; // PUT REQ FOR UPDATE ADMIN USER -module.exports.updateAdminUser = async (req, res) => { +module.exports.updateAdminUser = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -1854,7 +1829,7 @@ module.exports.updateAdminUser = async (req, res) => { // Get Mongoose Model const usersColConfig = project.collections.find(c => c.name === 'users'); - if (!usersColConfig) return res.status(404).json({ error: "Auth collection not found" }); + if (!usersColConfig) return next(new AppError(404, "Auth collection not found")); const connection = await getConnection(project._id); const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal); @@ -1866,11 +1841,11 @@ module.exports.updateAdminUser = async (req, res) => { ); if (result.matchedCount === 0) { - return res.status(404).json({ error: "User not found" }); + return next(new AppError(404, "User not found")); } res.json({ message: "User updated successfully" }); } catch (err) { - res.status(500).json({ error: err.message }); + return next(new AppError(500, err.message)); } }; From 4bac01800539543dfce1708e3dcbd652ae0752a8 Mon Sep 17 00:00:00 2001 From: Ayush Date: Wed, 17 Jun 2026 13:21:31 +0530 Subject: [PATCH 2/2] Code rabbit review cleared --- .../src/__tests__/storage.controller.test.js | 6 +- apps/public-api/src/app.js | 18 ++++-- .../src/controllers/data.controller.js | 12 ++-- .../src/controllers/schema.controller.js | 10 ++- .../src/controllers/storage.controller.js | 7 ++- .../src/controllers/userAuth.controller.js | 61 ++++++++++++------- 6 files changed, 72 insertions(+), 42 deletions(-) diff --git a/apps/public-api/src/__tests__/storage.controller.test.js b/apps/public-api/src/__tests__/storage.controller.test.js index b45e2f849..34f755cfb 100644 --- a/apps/public-api/src/__tests__/storage.controller.test.js +++ b/apps/public-api/src/__tests__/storage.controller.test.js @@ -245,7 +245,7 @@ describe('storage.controller', () => { { _id: 'project_id_1' }, { $inc: { storageUsed: -1024 } } ); - expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: {} }); }); test('returns 200 on successful external deletion and skips internal storageUsed update', async () => { @@ -260,7 +260,7 @@ describe('storage.controller', () => { expect(mockStorageFrom.list).toHaveBeenCalledWith('project_id_1', { search: 'file.txt', limit: 1 }); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: {} }); }); test('falls back to zero fileSize when Supabase list fails', async () => { @@ -274,7 +274,7 @@ describe('storage.controller', () => { await storageController.deleteFile(req, res, next); expect(Project.updateOne).not.toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: null }); + expect(res.json).toHaveBeenCalledWith({ success: true, message: 'File deleted successfully', data: {} }); }); test('returns 500 when Supabase remove fails', async () => { diff --git a/apps/public-api/src/app.js b/apps/public-api/src/app.js index 150b2cd75..fd968a729 100644 --- a/apps/public-api/src/app.js +++ b/apps/public-api/src/app.js @@ -93,8 +93,9 @@ app.get('/', (req, res) => { app.use((err, req, res, next) => { if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { return res.status(400).json({ - error: "Invalid JSON format", - message: "Check your request body syntax. Stray characters outside the JSON object are not allowed." + success: false, + data: {}, + message: "Invalid JSON format: Check your request body syntax. Stray characters outside the JSON object are not allowed." }); } @@ -103,21 +104,26 @@ app.use((err, req, res, next) => { if (err.isOperational && err.statusCode) { return res.status(err.statusCode).json({ success: false, - error: err.error || "Error", + data: err.error ? { error: err.error } : {}, message: err.message }); } console.error("🔥 Unhandled Error:", err.stack); res.status(500).json({ - error: "Something went wrong!", - message: err.message + success: false, + data: {}, + message: "Something went wrong!" }); }); app.use((req, res) => { const id = res.get("X-Kiroo-Replay-ID"); - res.json({error: "Not Found", replayId: id}) + res.status(404).json({ + success: false, + data: { replayId: id }, + message: "Not Found" + }); }) // INITIALIZATION if (process.env.NODE_ENV !== 'test') { diff --git a/apps/public-api/src/controllers/data.controller.js b/apps/public-api/src/controllers/data.controller.js index 9930708e0..bee1f3d37 100644 --- a/apps/public-api/src/controllers/data.controller.js +++ b/apps/public-api/src/controllers/data.controller.js @@ -99,10 +99,10 @@ module.exports.insertData = async (req, res, next) => { } if (isDuplicateKeyError(err)) { - return next(new AppError(409, "Duplicate value violates unique constraint.", err.message)); + return next(new AppError(409, "Duplicate value violates unique constraint.")); } - return next(new AppError(500, err.message)); + return next(new AppError(500, "An error occurred while processing your request")); } }; @@ -395,7 +395,7 @@ module.exports.getSingleDoc = async (req, res, next) => { if (process.env.NODE_ENV !== 'test') { console.error(err); } - return next(new AppError(500, err.message)); + return next(new AppError(500, "An error occurred while processing your request")); } }; @@ -468,7 +468,7 @@ module.exports.aggregateData = async (req, res, next) => { return next(new AppError(400, err.issues?.[0]?.message || "Invalid aggregation payload.", "Validation Error")); } - return next(new AppError(500, err.message || "Failed to execute aggregation.")); + return next(new AppError(500, "Failed to execute aggregation.")); } }; @@ -577,7 +577,7 @@ module.exports.updateSingleData = async (req, res, next) => { return next(new AppError(409, "Duplicate value violates unique constraint.")); } - return next(new AppError(500, err.message)); + return next(new AppError(500, "An error occurred while processing your request")); } }; @@ -642,7 +642,7 @@ module.exports.deleteSingleDoc = async (req, res, next) => { if (process.env.NODE_ENV !== 'test') { console.error(err); } - return next(new AppError(500, err.message)); + return next(new AppError(500, "An error occurred while processing your request")); } }; diff --git a/apps/public-api/src/controllers/schema.controller.js b/apps/public-api/src/controllers/schema.controller.js index e927f7027..67c86e18b 100644 --- a/apps/public-api/src/controllers/schema.controller.js +++ b/apps/public-api/src/controllers/schema.controller.js @@ -98,8 +98,8 @@ module.exports.createSchema = async (req, res, next) => { const isSupportedUniqueType = UNIQUE_SUPPORTED_TYPES.has(mappedType); if (wantsUnique && (!isTopLevel || !isSupportedUniqueType)) { - throw new Error( - `Field '${f.name}' can only use unique=true on top-level String, Number, Boolean, or Date fields.`, + throw new AppError(400, + `Field '${f.name}' can only use unique=true on top-level String, Number, Boolean, or Date fields.` ); } @@ -202,7 +202,11 @@ module.exports.createSchema = async (req, res, next) => { return next(new AppError(400, err.issues?.[0]?.message || "Invalid schema payload.")); } + if (err instanceof AppError) { + return next(err); + } + console.error(err); - return next(new AppError(400, err.message)); + return next(new AppError(500, "An error occurred while creating the schema.")); } }; diff --git a/apps/public-api/src/controllers/storage.controller.js b/apps/public-api/src/controllers/storage.controller.js index ef9177ab6..4001bb65d 100644 --- a/apps/public-api/src/controllers/storage.controller.js +++ b/apps/public-api/src/controllers/storage.controller.js @@ -165,6 +165,7 @@ module.exports.uploadFile = async (req, res, next) => { provider: external ? "external" : "internal" }, "File uploaded successfully").send(res, 201); } catch (err) { + if (err instanceof AppError) return next(err); return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File upload failed") : "File upload failed")); } }; @@ -225,8 +226,9 @@ module.exports.deleteFile = async (req, res, next) => { updateMonthlyUsageCounter(project._id, "storage:deletedBytes", fileSize); - return new ApiResponse(null, "File deleted successfully").send(res, 200); + return new ApiResponse({}, "File deleted successfully").send(res, 200); } catch (err) { + if (err instanceof AppError) return next(err); return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "File deletion failed") : "File deletion failed")); } }; @@ -281,6 +283,7 @@ module.exports.deleteAllFiles = async (req, res, next) => { }).send(res, 200); } catch (err) { + if (err instanceof AppError) return next(err); return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Failed to delete files") : "Failed to delete files")); } }; @@ -319,6 +322,7 @@ module.exports.requestUpload = async (req, res, next) => { return new ApiResponse({ signedUrl, token, filePath }).send(res, 200); } catch (err) { + if (err instanceof AppError) return next(err); return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Could not generate upload URL") : "Could not generate upload URL")); } }; @@ -401,6 +405,7 @@ module.exports.confirmUpload = async (req, res, next) => { return new ApiResponse(response, response.message).send(res, 200); } catch (err) { + if (err instanceof AppError) return next(err); return next(new AppError(500, process.env.NODE_ENV === "development" ? (err.message || "Upload confirmation failed") : "Upload confirmation failed")); } }; diff --git a/apps/public-api/src/controllers/userAuth.controller.js b/apps/public-api/src/controllers/userAuth.controller.js index a2fff92ba..e740d1338 100644 --- a/apps/public-api/src/controllers/userAuth.controller.js +++ b/apps/public-api/src/controllers/userAuth.controller.js @@ -1028,7 +1028,6 @@ module.exports.signup = async (req, res, next) => { userId: existingUser._id }); } - console.log('ABOUT TO CALL NEXT FOR EXISTING USER'); return next(new AppError(400, "User already exists with this email.")); } @@ -1084,10 +1083,10 @@ module.exports.signup = async (req, res, next) => { } catch (err) { if (err instanceof z.ZodError) { - return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); + return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); } - return next(new AppError(500, err.message)); - console.log(err) + console.error(err); + return next(new AppError(500, "An error occurred")); } } @@ -1190,8 +1189,9 @@ module.exports.login = async (req, res, next) => { }); } catch (err) { - if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); - return next(new AppError(500, err.message)); // Fixed: .json() + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + console.error(err); + return next(new AppError(500, "An error occurred")); // Fixed: .json() } } @@ -1233,7 +1233,8 @@ module.exports.me = async (req, res, next) => { } } catch (err) { - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } } @@ -1262,7 +1263,7 @@ module.exports.publicProfile = async (req, res, next) => { return new ApiResponse(profile, 'Public profile retrieved successfully').send(res, 200); } catch (err) { console.error("publicProfile ERROR:", err); - return next(new AppError(500, err.message)); + return next(new AppError(500, "An error occurred")); } } @@ -1306,9 +1307,10 @@ module.exports.createAdminUser = async (req, res, next) => { } catch (err) { if (err instanceof z.ZodError) { console.error(err); - return next(new AppError(400, err.issues?.[0]?.message || err.errors?.[0]?.message || "Validation failed")); + return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); } - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } } @@ -1345,7 +1347,8 @@ module.exports.resetPassword = async (req, res, next) => { res.json({ message: "Password updated successfully" }); } catch (err) { - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } } @@ -1375,7 +1378,7 @@ module.exports.verifyEmail = async (req, res, next) => { return next(new AppError(500, "No verification field found in users schema")); } const result = await Model.updateOne( - { email }, + { email: normalizedEmail }, { $set: { [verificationField]: true } } ); @@ -1386,7 +1389,8 @@ module.exports.verifyEmail = async (req, res, next) => { } catch (err) { if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1435,8 +1439,9 @@ module.exports.resendVerificationOtp = async (req, res, next) => { res.json({ message: "Verification code sent successfully." }); } catch (err) { - if (err instanceof z.ZodError) return next(new AppError(400, err.errors)); - return next(new AppError(500, err.message)); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1476,7 +1481,8 @@ module.exports.requestPasswordReset = async (req, res, next) => { res.json({ message: "If that email exists, a reset code has been sent." }); } catch (err) { if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1523,7 +1529,8 @@ module.exports.resetPasswordUser = async (req, res, next) => { } catch (err) { if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1598,7 +1605,8 @@ module.exports.updateProfile = async (req, res, next) => { res.json({ message: "Profile updated successfully" }); } catch (err) { - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1647,7 +1655,8 @@ module.exports.changePasswordUser = async (req, res, next) => { } catch (err) { if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || "Validation failed")); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1678,6 +1687,7 @@ module.exports.refreshToken = async (req, res, next) => { } if (String(req.project._id) !== String(session.projectId)) { + clearRefreshCookie(res); return next(new AppError(403, 'Refresh token does not belong to this project')); } @@ -1760,7 +1770,8 @@ module.exports.refreshToken = async (req, res, next) => { }); } catch (err) { clearRefreshCookie(res); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1773,6 +1784,7 @@ module.exports.logout = async (req, res, next) => { const session = await getRefreshSession(parsedToken.tokenId); if (session && hashRefreshToken(rawRefreshToken) === session.tokenHash) { if (String(req.project._id) !== String(session.projectId)) { + clearRefreshCookie(res); return next(new AppError(403, 'Refresh token does not belong to this project')); } session.revokedAt = new Date().toISOString(); @@ -1787,7 +1799,8 @@ module.exports.logout = async (req, res, next) => { return res.status(200).json({ success: true, message: 'Logged out successfully' }); } catch (err) { clearRefreshCookie(res); - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1811,7 +1824,8 @@ module.exports.getUserDetails = async (req, res, next) => { res.json(user); } catch (err) { - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } }; @@ -1846,6 +1860,7 @@ module.exports.updateAdminUser = async (req, res, next) => { res.json({ message: "User updated successfully" }); } catch (err) { - return next(new AppError(500, err.message)); + console.error(err); + return next(new AppError(500, "An error occurred")); } };