diff --git a/apps/dashboard-api/src/__tests__/auth.controller.test.js b/apps/dashboard-api/src/__tests__/auth.controller.test.js index 40c70c5b7..372e1cee5 100644 --- a/apps/dashboard-api/src/__tests__/auth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/auth.controller.test.js @@ -59,11 +59,30 @@ jest.mock('@urbackend/common', () => { create: jest.fn().mockResolvedValue(undefined), }; + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + return { Developer, Otp, Project, PlatformEvent, + AppError, + ApiResponse: class ApiResponse { + constructor(data = {}, message = 'Success') { + this.data = data; + this.message = message; + this.success = true; + } + send(res, statusCode = 200) { + return res.status(statusCode).json({ success: this.success, data: this.data, message: this.message }); + } + }, sendOtp: jest.fn().mockResolvedValue(undefined), // Use real zod shapes so validation logic is exercised. loginSchema: z.object({ @@ -98,7 +117,7 @@ jest.mock('@urbackend/common', () => { const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const { Developer, Otp, Project, sendOtp } = require('@urbackend/common'); +const { Developer, Otp, Project, sendOtp, AppError } = require('@urbackend/common'); const authController = require('../controllers/auth.controller'); // --------------------------------------------------------------------------- @@ -124,6 +143,8 @@ const makeReq = (body = {}, user = null, cookies = {}) => ({ cookies, }); +const next = jest.fn(); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -149,11 +170,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'new@example.com', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); expect(Developer.findOne).toHaveBeenCalledWith({ email: 'new@example.com' }); expect(res.status).toHaveBeenCalledWith(201); - expect(res.json).toHaveBeenCalledWith({ message: 'Registered successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, data: {}, message: 'Registered successfully' }); }); test('returns 400 when email already exists', async () => { @@ -162,19 +183,20 @@ describe('auth.controller', () => { const req = makeReq({ email: 'existing@example.com', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Email already exists' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 400 on Zod validation error (invalid email)', async () => { const req = makeReq({ email: 'not-an-email', password: 'password123' }); const res = makeRes(); - await authController.register(req, res); + await authController.register(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -199,7 +221,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'test@example.com', password: 'correctpass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('correctpass', 'hashed_password'); expect(res.status).toHaveBeenCalledWith(200); @@ -216,10 +238,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'noone@example.com', password: 'pass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('User not found'); }); test('returns 400 on invalid password', async () => { @@ -229,19 +252,21 @@ describe('auth.controller', () => { const req = makeReq({ email: 'test@example.com', password: 'wrongpass' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Invalid password'); }); test('returns 400 on Zod validation error (missing password)', async () => { const req = makeReq({ email: 'test@example.com' }); const res = makeRes(); - await authController.login(req, res); + await authController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -266,7 +291,7 @@ describe('auth.controller', () => { const req = makeReq({}, null, { refreshToken: 'valid_refresh_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); expect(jwt.verify).toHaveBeenCalledWith('valid_refresh_token', 'refresh-secret'); expect(res.status).toHaveBeenCalledWith(200); @@ -276,21 +301,27 @@ describe('auth.controller', () => { const req = makeReq({}, null, {}); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'No refresh token provided' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); + expect(next.mock.calls[0][0].message).toBe('No refresh token provided'); }); test('returns 403 when refresh token is invalid (jwt.verify throws)', async () => { - jwt.verify.mockImplementation(() => { throw new Error('invalid token'); }); + jwt.verify.mockImplementation(() => { + const err = new Error('invalid token'); + err.name = 'JsonWebTokenError'; + throw err; + }); const req = makeReq({}, null, { refreshToken: 'bad_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); }); test('returns 403 when stored refresh token does not match', async () => { @@ -300,10 +331,11 @@ describe('auth.controller', () => { const req = makeReq({}, null, { refreshToken: 'valid_refresh_token' }); const res = makeRes(); - await authController.refreshToken(req, res); + await authController.refreshToken(req, res, next); - expect(res.status).toHaveBeenCalledWith(403); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid refresh token' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(403); + expect(next.mock.calls[0][0].message).toBe('Invalid refresh token'); }); }); @@ -320,13 +352,14 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'dev_id_1' }); const res = makeRes(); - await authController.logout(req, res); + await authController.logout(req, res, next); expect(mockUser.save).toHaveBeenCalled(); expect(mockUser.refreshToken).toBeNull(); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ success: true, + data: {}, message: 'Logged out successfully', }); }); @@ -335,7 +368,7 @@ describe('auth.controller', () => { const req = makeReq(); const res = makeRes(); - await authController.logout(req, res); + await authController.logout(req, res, next); expect(res.status).toHaveBeenCalledWith(200); }); @@ -353,7 +386,7 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'dev_id_1' }); const res = makeRes(); - await authController.getMe(req, res); + await authController.getMe(req, res, next); expect(Developer.findById).toHaveBeenCalledWith('dev_id_1'); expect(mockSelect).toHaveBeenCalledWith('-password -refreshToken'); @@ -370,10 +403,11 @@ describe('auth.controller', () => { const req = makeReq({}, { _id: 'missing_id' }); const res = makeRes(); - await authController.getMe(req, res); + await authController.getMe(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User 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('User not found'); }); }); @@ -396,11 +430,13 @@ describe('auth.controller', () => { ); const res = makeRes(); - await authController.changePassword(req, res); + await authController.changePassword(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('oldpass', 'old_hashed'); expect(mockUser.save).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith({ + success: true, + data: {}, message: 'Password updated successfully', }); }); @@ -418,10 +454,11 @@ describe('auth.controller', () => { ); const res = makeRes(); - await authController.changePassword(req, res); + await authController.changePassword(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Incorrect current password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Incorrect current password'); }); }); @@ -433,10 +470,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'noone@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found. Ensure you are using the correct email.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('User not found. Ensure you are using the correct email.'); }); test('returns 400 when user is already verified', async () => { @@ -445,10 +483,11 @@ describe('auth.controller', () => { const req = makeReq({ email: 'verified@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Account is already verified. Please login.' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); + expect(next.mock.calls[0][0].message).toBe('Account is already verified. Please login.'); }); test('sends OTP and returns success for unverified user', async () => { @@ -464,10 +503,10 @@ describe('auth.controller', () => { const req = makeReq({ email: 'unverified@example.com' }); const res = makeRes(); - await authController.sendOtp(req, res); + await authController.sendOtp(req, res, next); expect(sendOtp).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'OTP sent successfully' }); + expect(res.json).toHaveBeenCalledWith({ success: true, data: {}, message: 'OTP sent successfully' }); }); }); @@ -479,7 +518,7 @@ describe('auth.controller', () => { const req = makeReq({ email: 'ghost@example.com' }); const res = makeRes(); - await authController.forgotPassword(req, res); + await authController.forgotPassword(req, res, next); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith( @@ -518,7 +557,7 @@ describe('auth.controller', () => { }); const res = makeRes(); - await authController.resetPassword(req, res); + await authController.resetPassword(req, res, next); expect(mockUser.refreshToken).toBeNull(); expect(mockUser.save).toHaveBeenCalled(); @@ -534,6 +573,8 @@ describe('auth.controller', () => { ); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ + success: true, + data: {}, message: 'Password reset successfully. Please log in with your new password.' }); }); diff --git a/apps/dashboard-api/src/__tests__/billing.controller.test.js b/apps/dashboard-api/src/__tests__/billing.controller.test.js index a8a670b03..2cf899020 100644 --- a/apps/dashboard-api/src/__tests__/billing.controller.test.js +++ b/apps/dashboard-api/src/__tests__/billing.controller.test.js @@ -9,6 +9,20 @@ class mockAppError extends Error { } } +class mockApiResponse { + constructor(data, message = '') { + this.data = data; + this.message = message; + } + send(res) { + return res.json({ + success: true, + data: this.data, + message: this.message + }); + } +} + jest.mock('@urbackend/common', () => ({ Developer: { findById: jest.fn().mockReturnThis(), @@ -23,6 +37,7 @@ jest.mock('@urbackend/common', () => ({ sort: jest.fn().mockReturnThis(), }, AppError: mockAppError, + ApiResponse: mockApiResponse, sendProRequestConfirmationEmail: jest.fn().mockResolvedValue(true), sanitizeNonEmptyString: jest.fn(str => (typeof str === 'string' && str.trim() !== '' ? str.trim() : null)), sanitizeObjectId: jest.fn(id => id), @@ -36,7 +51,7 @@ jest.mock('razorpay', () => { })); }); -const { Developer, ProRequest, sendProRequestConfirmationEmail } = require('@urbackend/common'); +const { Developer, ProRequest, sendProRequestConfirmationEmail, AppError } = require('@urbackend/common'); const controller = require('../controllers/billing.controller'); describe('Billing Controller', () => { @@ -175,8 +190,9 @@ describe('Billing Controller', () => { describe('handleWebhook', () => { test('returns 401 if signature is missing', async () => { await controller.handleWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ success: false })); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); + expect(next.mock.calls[0][0].message).toBe('Missing webhook signature.'); }); test('returns 401 if signature is invalid', async () => { @@ -184,7 +200,8 @@ describe('Billing Controller', () => { req.body = { event: 'subscription.activated' }; await controller.handleWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('processes valid webhook signature and upgrades plan', async () => { diff --git a/apps/dashboard-api/src/__tests__/userAuth.controller.test.js b/apps/dashboard-api/src/__tests__/userAuth.controller.test.js index 13950525e..8cdb112d1 100644 --- a/apps/dashboard-api/src/__tests__/userAuth.controller.test.js +++ b/apps/dashboard-api/src/__tests__/userAuth.controller.test.js @@ -22,6 +22,14 @@ jest.mock('@urbackend/common', () => { updateOne: jest.fn(), }; + class MockAppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + return { Project: { findOne: jest.fn() }, redis: { @@ -32,6 +40,21 @@ jest.mock('@urbackend/common', () => { authEmailQueue: { add: jest.fn().mockResolvedValue(undefined), }, + AppError: MockAppError, + ApiResponse: class ApiResponse { + constructor(data = {}, message = "Success") { + this.data = data; + this.message = message; + this.success = true; + } + send(res, statusCode = 200) { + return res.status(statusCode).json({ + success: this.success, + data: this.data, + message: this.message + }); + } + }, loginSchema: z.object({ email: z.string().email(), password: z.string().min(1), @@ -74,7 +97,7 @@ jest.mock('@urbackend/common', () => { const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); -const { redis, authEmailQueue, getConnection, getCompiledModel, __mockModel: mockModel } = +const { redis, authEmailQueue, getConnection, getCompiledModel, __mockModel: mockModel, AppError } = require('@urbackend/common'); const userAuthController = require('../controllers/userAuth.controller'); @@ -82,6 +105,8 @@ const userAuthController = require('../controllers/userAuth.controller'); // Helpers // --------------------------------------------------------------------------- +const next = jest.fn(); + const makeRes = () => { const res = { status: jest.fn(), json: jest.fn(), header: jest.fn() }; res.status.mockReturnValue(res); @@ -114,6 +139,7 @@ const makeProject = (overrides = {}) => ({ describe('userAuth.controller', () => { beforeEach(() => { jest.clearAllMocks(); + next.mockClear(); process.env.NODE_ENV = 'test'; }); @@ -132,7 +158,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); expect(mockModel.create).toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalledWith( @@ -141,7 +167,7 @@ describe('userAuth.controller', () => { ); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ token: 'signup_token' }) + expect.objectContaining({ data: expect.objectContaining({ token: 'signup_token' }) }) ); }); @@ -154,12 +180,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'User already exists with this email.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 404 when users collection is not configured', async () => { @@ -169,10 +193,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Auth collection not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('returns 400 on Zod validation error (short password)', async () => { @@ -182,9 +206,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.signup(req, res); + await userAuthController.signup(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -207,10 +232,12 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); expect(bcrypt.compare).toHaveBeenCalledWith('correct', 'hashed_pw'); - expect(res.json).toHaveBeenCalledWith({ token: 'user_token' }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ data: { token: 'user_token' } }) + ); }); test('returns 400 when user is not found', async () => { @@ -222,10 +249,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid email or password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 400 when password is wrong', async () => { @@ -238,10 +265,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid email or password' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns 404 when users collection is missing', async () => { @@ -251,9 +278,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.login(req, res); + await userAuthController.login(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -266,12 +294,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ - error: 'Access Denied: No Token Provided', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('returns 401 when JWT is invalid', async () => { @@ -283,10 +309,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(401); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or Expired Token' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(401); }); test('returns 404 when user is not found in database', async () => { @@ -300,10 +326,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('returns user data on valid token', async () => { @@ -318,9 +344,11 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.me(req, res); + await userAuthController.me(req, res, next); - expect(res.json).toHaveBeenCalledWith(userData); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ data: { user: userData } }) + ); }); }); @@ -335,10 +363,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired OTP' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); test('returns success when OTP matches and user is found', async () => { @@ -351,10 +379,12 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); expect(redis.del).toHaveBeenCalled(); - expect(res.json).toHaveBeenCalledWith({ message: 'Email verified successfully' }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Email verified successfully' }) + ); }); test('returns 404 when no matching user record is updated', async () => { @@ -367,10 +397,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.verifyEmail(req, res); + await userAuthController.verifyEmail(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'User not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -385,7 +415,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.requestPasswordReset(req, res); + await userAuthController.requestPasswordReset(req, res, next); expect(authEmailQueue.add).not.toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith( @@ -402,7 +432,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.requestPasswordReset(req, res); + await userAuthController.requestPasswordReset(req, res, next); expect(redis.set).toHaveBeenCalled(); expect(authEmailQueue.add).toHaveBeenCalledWith( @@ -429,7 +459,7 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.createAdminUser(req, res); + await userAuthController.createAdminUser(req, res, next); expect(mockModel.create).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); @@ -447,12 +477,10 @@ describe('userAuth.controller', () => { }; const res = makeRes(); - await userAuthController.createAdminUser(req, res); + await userAuthController.createAdminUser(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ - error: 'User already exists with this email.', - }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); }); diff --git a/apps/dashboard-api/src/__tests__/webhook.controller.test.js b/apps/dashboard-api/src/__tests__/webhook.controller.test.js index 46484b493..f022982f7 100644 --- a/apps/dashboard-api/src/__tests__/webhook.controller.test.js +++ b/apps/dashboard-api/src/__tests__/webhook.controller.test.js @@ -3,31 +3,56 @@ const mongoose = require('mongoose'); // Mock @urbackend/common -jest.mock('@urbackend/common', () => ({ - Webhook: { - create: jest.fn(), - find: jest.fn(), - findOne: jest.fn(), - findOneAndUpdate: jest.fn(), - findOneAndDelete: jest.fn(), - }, - WebhookDelivery: { - find: jest.fn(), - countDocuments: jest.fn(), - }, - Project: { - findOne: jest.fn(), - }, - encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), - decrypt: jest.fn(() => 'decrypted-secret'), - createWebhookSchema: { - safeParse: jest.fn(), - }, - updateWebhookSchema: { - safeParse: jest.fn(), - }, - generateSignature: jest.fn(() => 'sha256=test-signature'), -})); +jest.mock('@urbackend/common', () => { + class AppError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + this.isOperational = true; + } + } + + return { + Webhook: { + create: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findOneAndUpdate: jest.fn(), + findOneAndDelete: jest.fn(), + }, + WebhookDelivery: { + find: jest.fn(), + countDocuments: jest.fn(), + }, + Project: { + findOne: jest.fn(), + }, + AppError, + encrypt: jest.fn((val) => ({ encrypted: 'enc', iv: 'iv', tag: 'tag' })), + decrypt: jest.fn(() => 'decrypted-secret'), + createWebhookSchema: { + safeParse: jest.fn(), + }, + updateWebhookSchema: { + safeParse: jest.fn(), + }, + generateSignature: jest.fn(() => 'sha256=test-signature'), + ApiResponse: class ApiResponse { + constructor(data = {}, message = "Success") { + this.data = data; + this.message = message; + this.success = true; + } + send(res, statusCode = 200) { + return res.status(statusCode).json({ + success: this.success, + data: this.data, + message: this.message + }); + } + }, + }; +}); const { createWebhook, @@ -43,12 +68,13 @@ const { Webhook, WebhookDelivery, Project, + AppError, createWebhookSchema, updateWebhookSchema, } = require('@urbackend/common'); describe('webhook.controller', () => { - let req, res; + let req, res, next; // Use valid MongoDB ObjectId format const validProjectId = new mongoose.Types.ObjectId().toString(); const validWebhookId = new mongoose.Types.ObjectId().toString(); @@ -65,6 +91,7 @@ describe('webhook.controller', () => { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), }; + next = jest.fn(); }); describe('createWebhook', () => { @@ -99,7 +126,7 @@ describe('webhook.controller', () => { events: { posts: { insert: true } }, }; - await createWebhook(req, res); + await createWebhook(req, res, next); expect(Project.findOne).toHaveBeenCalledWith({ _id: validProjectId, @@ -120,10 +147,10 @@ describe('webhook.controller', () => { test('returns 404 if project not found', async () => { Project.findOne.mockResolvedValue(null); - await createWebhook(req, res); + await createWebhook(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); }); test('returns 400 on validation failure', async () => { @@ -133,12 +160,10 @@ describe('webhook.controller', () => { error: { errors: [{ message: 'Invalid URL' }] }, }); - await createWebhook(req, res); + await createWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: 'Validation failed' }) - ); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(400); }); }); @@ -152,15 +177,17 @@ describe('webhook.controller', () => { ]), }); - await getWebhooks(req, res); + await getWebhooks(req, res, next); expect(Webhook.find).toHaveBeenCalledWith({ projectId: validProjectId }); - expect(res.json).toHaveBeenCalledWith({ - data: expect.arrayContaining([ - expect.objectContaining({ name: 'Hook 1' }), - expect.objectContaining({ name: 'Hook 2' }), - ]), - }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.arrayContaining([ + expect.objectContaining({ name: 'Hook 1' }), + expect.objectContaining({ name: 'Hook 2' }), + ]), + }) + ); }); }); @@ -177,11 +204,13 @@ describe('webhook.controller', () => { }), }); - await getWebhook(req, res); + await getWebhook(req, res, next); - expect(res.json).toHaveBeenCalledWith({ - data: expect.objectContaining({ name: 'Test Hook' }), - }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ name: 'Test Hook' }), + }) + ); }); test('returns 404 if webhook not found', async () => { @@ -191,10 +220,10 @@ describe('webhook.controller', () => { lean: jest.fn().mockResolvedValue(null), }); - await getWebhook(req, res); + await getWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -217,7 +246,7 @@ describe('webhook.controller', () => { }), }); - await updateWebhook(req, res); + await updateWebhook(req, res, next); expect(Webhook.findOneAndUpdate).toHaveBeenCalled(); expect(res.json).toHaveBeenCalledWith( @@ -235,13 +264,15 @@ describe('webhook.controller', () => { Project.findOne.mockResolvedValue({ _id: validProjectId }); Webhook.findOneAndDelete.mockResolvedValue({ _id: validWebhookId }); - await deleteWebhook(req, res); + await deleteWebhook(req, res, next); expect(Webhook.findOneAndDelete).toHaveBeenCalledWith({ _id: validWebhookId, projectId: validProjectId, }); - expect(res.json).toHaveBeenCalledWith({ message: 'Webhook deleted' }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Webhook deleted' }) + ); }); test('returns 404 if webhook not found', async () => { @@ -249,10 +280,10 @@ describe('webhook.controller', () => { Project.findOne.mockResolvedValue({ _id: validProjectId }); Webhook.findOneAndDelete.mockResolvedValue(null); - await deleteWebhook(req, res); + await deleteWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); }); @@ -277,16 +308,20 @@ describe('webhook.controller', () => { }); WebhookDelivery.countDocuments.mockResolvedValue(2); - await getDeliveries(req, res); + await getDeliveries(req, res, next); - expect(res.json).toHaveBeenCalledWith({ - data: mockDeliveries, - pagination: expect.objectContaining({ - page: 1, - limit: 10, - total: 2, - }), - }); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + deliveries: mockDeliveries, + pagination: expect.objectContaining({ + page: 1, + limit: 10, + total: 2, + }), + }), + }) + ); }); }); @@ -320,7 +355,7 @@ describe('webhook.controller', () => { text: jest.fn().mockResolvedValue('{"received": true}'), }); - await testWebhook(req, res); + await testWebhook(req, res, next); expect(global.fetch).toHaveBeenCalledWith( 'https://example.com/webhook', @@ -334,8 +369,10 @@ describe('webhook.controller', () => { }) ); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: true, - statusCode: 200, + data: expect.objectContaining({ + success: true, + statusCode: 200, + }) })); }); @@ -347,10 +384,10 @@ describe('webhook.controller', () => { select: jest.fn().mockResolvedValue(null), }); - await testWebhook(req, res); + await testWebhook(req, res, next); - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'Webhook not found' }); + expect(next).toHaveBeenCalledWith(expect.any(AppError)); + expect(next.mock.calls[0][0].statusCode).toBe(404); }); test('handles fetch failure gracefully', async () => { @@ -371,11 +408,13 @@ describe('webhook.controller', () => { global.fetch.mockRejectedValue(new Error('Network error')); - await testWebhook(req, res); + await testWebhook(req, res, next); expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ - success: false, - error: 'Network error', + data: expect.objectContaining({ + success: false, + error: 'Network error', + }) })); }); }); diff --git a/apps/dashboard-api/src/app.js b/apps/dashboard-api/src/app.js index ec98870de..b93c37a1d 100644 --- a/apps/dashboard-api/src/app.js +++ b/apps/dashboard-api/src/app.js @@ -147,18 +147,19 @@ app.use((err, req, res, next) => { }); } - const statusCode = err.statusCode || 500; - const message = err.message || "Something went wrong!"; - - // Only log actual server errors (500), not expected operational errors (4xx) - if (statusCode >= 500) { - console.error("🔥 Server Error:", err.stack); + if (err.isOperational && err.statusCode) { + return res.status(err.statusCode).json({ + success: false, + error: err.error || "Error", + message: err.message + }); } - res.status(statusCode).json({ + console.error("🔥 Unhandled Server Error:", err.stack); + res.status(500).json({ success: false, - error: statusCode >= 500 ? "Internal Server Error" : message, - message: message + error: "Something went wrong!", + message: "Internal server error" }); }); diff --git a/apps/dashboard-api/src/controllers/admin.metrics.controller.js b/apps/dashboard-api/src/controllers/admin.metrics.controller.js index 902745f64..6c51bbd0c 100644 --- a/apps/dashboard-api/src/controllers/admin.metrics.controller.js +++ b/apps/dashboard-api/src/controllers/admin.metrics.controller.js @@ -1,413 +1,379 @@ -const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity } = require('@urbackend/common'); - -/** - * Guard: only callable by the platform admin. - * Checked upstream via the isAdmin flag on the JWT payload, - * but we double-check here for defence in depth. - */ -function requireAdmin(req, res, next) { - if (!req.user?.isAdmin) { - return res.status(403).json({ success: false, data: {}, message: 'Admin access required.' }); - } - return next(); -} - -module.exports.requireAdmin = requireAdmin; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/overview -// Platform-wide snapshot: signups, verified devs, active projects, total calls. -// --------------------------------------------------------------------------- -module.exports.getOverview = async (req, res) => { - try { - const sevenDaysAgo = new Date(); - sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - - const [ - totalDevelopers, - verifiedDevelopers, - totalProjects, - totalApiCalls, - northStarProjects, - ] = await Promise.all([ - Developer.countDocuments(), - Developer.countDocuments({ isVerified: true }), - Project.countDocuments(), - ApiAnalytics.countDocuments(), - ApiAnalytics.distinct('projectId', { - statusCode: { $gte: 200, $lt: 300 }, - timestamp: { $gte: sevenDaysAgo }, - }), - ]); - - return res.json({ - success: true, - data: { - totalDevelopers, - verifiedDevelopers, - totalProjects, - totalApiCalls, - activeProjectsLast7d: northStarProjects.length, - }, - message: '', - }); - } catch (err) { - console.error('[admin.metrics] getOverview error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/activation-funnel -// Platform-wide funnel: count of devs who completed each activation step. -// --------------------------------------------------------------------------- -module.exports.getActivationFunnel = async (req, res) => { - try { - const FUNNEL_STEPS = [ - 'signup_completed', - 'email_verified', - 'project_created', - 'collection_created', - 'first_api_success', - ]; - - const counts = await PlatformEvent.aggregate([ - { $match: { event: { $in: FUNNEL_STEPS } } }, - { - $group: { - _id: { event: '$event', developerId: '$developerId' }, - }, - }, - { - $group: { - _id: '$_id.event', - count: { $sum: 1 }, - }, - }, - { - $project: { - event: '$_id', - count: 1, - _id: 0, - }, - }, - ]); - - const countMap = {}; - for (const row of counts) { - countMap[row.event] = row.count; - } - - const steps = FUNNEL_STEPS.map((step, i) => ({ - step, - order: i + 1, - uniqueDevs: countMap[step] || 0, - })); - - return res.json({ success: true, data: { steps }, message: '' }); - } catch (err) { - console.error('[admin.metrics] getActivationFunnel error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/cohorts?month=2026-05 -// D1/D7/D30 retention for developers who signed up in a given month. -// --------------------------------------------------------------------------- -module.exports.getCohorts = async (req, res) => { - try { - const { month } = req.query; // e.g. "2026-05" - if (!month || !/^\d{4}-\d{2}$/.test(month)) { - return res.status(400).json({ - success: false, - data: {}, - message: 'Provide month as YYYY-MM (e.g. 2026-05)', - }); - } - - const [year, mo] = month.split('-').map(Number); - const cohortStart = new Date(Date.UTC(year, mo - 1, 1)); - const cohortEnd = new Date(Date.UTC(year, mo, 1)); - - // Developers who signed up in this cohort month - const signups = await PlatformEvent.aggregate([ - { - $match: { - event: 'signup_completed', - timestamp: { $gte: cohortStart, $lt: cohortEnd }, - developerId: { $ne: null }, - }, - }, - { $sort: { timestamp: 1 } }, - { - $group: { - _id: '$developerId', - signupTimestamp: { $first: '$timestamp' }, - }, - }, - ]); - - const cohortSize = signups.length; - if (cohortSize === 0) { - return res.json({ - success: true, - data: { month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }, - message: '', - }); - } - - const DAY_MS = 24 * 60 * 60 * 1000; - const toUtcDay = (date) => { - const d = new Date(date); - d.setUTCHours(0, 0, 0, 0); - return d; - }; - const toUtcDayKey = (date) => toUtcDay(date).toISOString(); - - const targetOffsets = [1, 7, 30]; - const targetKeySets = { - 1: new Set(), - 7: new Set(), - 30: new Set(), - }; - - let minTarget = null; - let maxTarget = null; - - for (const signup of signups) { - const developerKey = signup._id.toString(); - const signupDay = toUtcDay(signup.signupTimestamp); - for (const offset of targetOffsets) { - const target = new Date(signupDay.getTime() + offset * DAY_MS); - const targetKey = `${developerKey}:${toUtcDayKey(target)}`; - targetKeySets[offset].add(targetKey); - if (!minTarget || target < minTarget) minTarget = target; - if (!maxTarget || target > maxTarget) maxTarget = target; - } - } - - const activities = await DeveloperActivity.find({ - developerId: { $in: signups.map((s) => s._id) }, - date: { $gte: minTarget, $lt: new Date(maxTarget.getTime() + DAY_MS) }, - }) - .select('developerId date') - .lean(); - - const activeKeySet = new Set( - activities.map((activity) => `${activity.developerId.toString()}:${toUtcDayKey(activity.date)}`), - ); - - const countRetained = (offset) => { - let retained = 0; - for (const key of targetKeySets[offset]) { - if (activeKeySet.has(key)) retained++; - } - return retained; - }; - - const d1 = countRetained(1); - const d7 = countRetained(7); - const d30 = countRetained(30); - - return res.json({ - success: true, - data: { - month, - cohortSize, - d1, - d7, - d30, - d1Pct: Math.round((d1 / cohortSize) * 100), - d7Pct: Math.round((d7 / cohortSize) * 100), - d30Pct: Math.round((d30 / cohortSize) * 100), - }, - message: '', - }); - } catch (err) { - console.error('[admin.metrics] getCohorts error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/feature-usage -// Platform-wide feature breakdown from DeveloperActivity (last 30 days). -// --------------------------------------------------------------------------- -module.exports.getFeatureUsage = async (req, res) => { - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); - - const agg = await DeveloperActivity.aggregate([ - { $match: { date: { $gte: thirtyDaysAgo } } }, - { - $facet: { - totals: [ - { - $group: { - _id: null, - totalApiCalls: { $sum: '$apiCallCount' }, - totalMailSent: { $sum: '$mailSentCount' }, - totalStorageUploads: { $sum: '$storageUploadsCount' }, - totalWebhooksFired: { $sum: '$webhookTriggeredCount' }, - }, - }, - ], - activeDevelopers: [ - { $group: { _id: '$developerId' } }, - { $count: 'count' }, - ], - }, - }, - ]); - - const result = agg[0]?.totals?.[0] || { - totalApiCalls: 0, - totalMailSent: 0, - totalStorageUploads: 0, - totalWebhooksFired: 0, - }; - const activeDeveloperCount = agg[0]?.activeDevelopers?.[0]?.count || 0; - - return res.json({ - success: true, - data: { - window: '30d', - totalApiCalls: result.totalApiCalls, - totalMailSent: result.totalMailSent, - totalStorageUploads: result.totalStorageUploads, - totalWebhooksFired: result.totalWebhooksFired, - activeDevelopers: activeDeveloperCount, - }, - message: '', - }); - } catch (err) { - console.error('[admin.metrics] getFeatureUsage error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/reliability -// Global error rate and latency across all projects (last 24h from ApiAnalytics). -// --------------------------------------------------------------------------- -module.exports.getReliability = async (req, res) => { - try { - const { ApiAnalytics } = require('@urbackend/common'); - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - - const agg = await ApiAnalytics.aggregate([ - { $match: { timestamp: { $gte: since } } }, - { - $group: { - _id: null, - total: { $sum: 1 }, - errors: { $sum: { $cond: [{ $gte: ['$statusCode', 500] }, 1, 0] } }, - p50: { $percentile: { input: '$responseTimeMs', p: [0.5], method: 'approximate' } }, - p95: { $percentile: { input: '$responseTimeMs', p: [0.95], method: 'approximate' } }, - p99: { $percentile: { input: '$responseTimeMs', p: [0.99], method: 'approximate' } }, - }, - }, - ]); - - const r = agg[0] || { total: 0, errors: 0, p50: [0], p95: [0], p99: [0] }; - - return res.json({ - success: true, - data: { - window: '24h', - totalRequests: r.total, - errorCount: r.errors, - errorRate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(2) : '0.00', - p50Ms: r.p50?.[0]?.toFixed(1) ?? null, - p95Ms: r.p95?.[0]?.toFixed(1) ?? null, - p99Ms: r.p99?.[0]?.toFixed(1) ?? null, - }, - message: '', - }); - } catch (err) { - console.error('[admin.metrics] getReliability error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/top-projects -// Most active projects by API calls in the last 7 days. -// --------------------------------------------------------------------------- -module.exports.getTopProjects = async (req, res) => { - try { - const sevenDaysAgo = new Date(); - sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); - - const agg = await Log.aggregate([ - { $match: { timestamp: { $gte: sevenDaysAgo } } }, - { $group: { _id: '$projectId', callCount: { $sum: 1 } } }, - { $sort: { callCount: -1 } }, - { $limit: 20 }, - { - $lookup: { - from: 'projects', - localField: '_id', - foreignField: '_id', - as: 'project', - }, - }, - { - $project: { - _id: 0, - projectId: '$_id', - callCount: 1, - projectName: { $arrayElemAt: ['$project.name', 0] }, - }, - }, - ]); - - return res.json({ success: true, data: { projects: agg }, message: '' }); - } catch (err) { - console.error('[admin.metrics] getTopProjects error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; - -// --------------------------------------------------------------------------- -// GET /api/admin/metrics/churn-signals -// Projects with zero API calls in the last 14 days that had prior activity. -// --------------------------------------------------------------------------- -module.exports.getChurnSignals = async (req, res) => { - try { - const fourteenDaysAgo = new Date(); - fourteenDaysAgo.setUTCDate(fourteenDaysAgo.getUTCDate() - 14); - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); - - // Projects active in 30–14d window - const prevActive = await Log.distinct('projectId', { - timestamp: { $gte: thirtyDaysAgo, $lt: fourteenDaysAgo }, - }); - - // Projects that were active but have ZERO calls in last 14d - const recentlyActive = await Log.distinct('projectId', { - timestamp: { $gte: fourteenDaysAgo }, - }); - const recentSet = new Set(recentlyActive.map(String)); - - const churnedIds = prevActive.filter((id) => !recentSet.has(String(id))); - - const projects = await Project.find({ _id: { $in: churnedIds } }) - .select('name owner createdAt') - .populate('owner', 'email') - .limit(50) - .lean(); - - return res.json({ - success: true, - data: { churnSignals: churnedIds.length, projects }, - message: '', - }); - } catch (err) { - console.error('[admin.metrics] getChurnSignals error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); - } -}; +const { Developer, Project, Log, ApiAnalytics, PlatformEvent, DeveloperActivity, AppError, ApiResponse } = require('@urbackend/common'); + +/** + * Guard: only callable by the platform admin. + * Checked upstream via the isAdmin flag on the JWT payload, + * but we double-check here for defence in depth. + */ +function requireAdmin(req, res, next) { + if (!req.user?.isAdmin) { + return next(new AppError(403, 'Admin access required.')); + } + return next(); +} + +module.exports.requireAdmin = requireAdmin; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/overview +// Platform-wide snapshot: signups, verified devs, active projects, total calls. +// --------------------------------------------------------------------------- +module.exports.getOverview = async (req, res, next) => { + try { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + + const [ + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + northStarProjects, + ] = await Promise.all([ + Developer.countDocuments(), + Developer.countDocuments({ isVerified: true }), + Project.countDocuments(), + ApiAnalytics.countDocuments(), + ApiAnalytics.distinct('projectId', { + statusCode: { $gte: 200, $lt: 300 }, + timestamp: { $gte: sevenDaysAgo }, + }), + ]); + + return new ApiResponse({ + totalDevelopers, + verifiedDevelopers, + totalProjects, + totalApiCalls, + activeProjectsLast7d: northStarProjects.length, + }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/activation-funnel +// Platform-wide funnel: count of devs who completed each activation step. +// --------------------------------------------------------------------------- +module.exports.getActivationFunnel = async (req, res, next) => { + try { + const FUNNEL_STEPS = [ + 'signup_completed', + 'email_verified', + 'project_created', + 'collection_created', + 'first_api_success', + ]; + + const counts = await PlatformEvent.aggregate([ + { $match: { event: { $in: FUNNEL_STEPS } } }, + { + $group: { + _id: { event: '$event', developerId: '$developerId' }, + }, + }, + { + $group: { + _id: '$_id.event', + count: { $sum: 1 }, + }, + }, + { + $project: { + event: '$_id', + count: 1, + _id: 0, + }, + }, + ]); + + const countMap = {}; + for (const row of counts) { + countMap[row.event] = row.count; + } + + const steps = FUNNEL_STEPS.map((step, i) => ({ + step, + order: i + 1, + uniqueDevs: countMap[step] || 0, + })); + + return new ApiResponse({ steps }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/cohorts?month=2026-05 +// D1/D7/D30 retention for developers who signed up in a given month. +// --------------------------------------------------------------------------- +module.exports.getCohorts = async (req, res, next) => { + try { + const { month } = req.query; // e.g. "2026-05" + if (!month || !/^\d{4}-\d{2}$/.test(month)) { + return next(new AppError(400, 'Provide month as YYYY-MM (e.g. 2026-05)')); + } + + const [year, mo] = month.split('-').map(Number); + const cohortStart = new Date(Date.UTC(year, mo - 1, 1)); + const cohortEnd = new Date(Date.UTC(year, mo, 1)); + + // Developers who signed up in this cohort month + const signups = await PlatformEvent.aggregate([ + { + $match: { + event: 'signup_completed', + timestamp: { $gte: cohortStart, $lt: cohortEnd }, + developerId: { $ne: null }, + }, + }, + { $sort: { timestamp: 1 } }, + { + $group: { + _id: '$developerId', + signupTimestamp: { $first: '$timestamp' }, + }, + }, + ]); + + const cohortSize = signups.length; + if (cohortSize === 0) { + return new ApiResponse({ month, cohortSize: 0, d1: 0, d7: 0, d30: 0 }).send(res); + } + + const DAY_MS = 24 * 60 * 60 * 1000; + const toUtcDay = (date) => { + const d = new Date(date); + d.setUTCHours(0, 0, 0, 0); + return d; + }; + const toUtcDayKey = (date) => toUtcDay(date).toISOString(); + + const targetOffsets = [1, 7, 30]; + const targetKeySets = { + 1: new Set(), + 7: new Set(), + 30: new Set(), + }; + + let minTarget = null; + let maxTarget = null; + + for (const signup of signups) { + const developerKey = signup._id.toString(); + const signupDay = toUtcDay(signup.signupTimestamp); + for (const offset of targetOffsets) { + const target = new Date(signupDay.getTime() + offset * DAY_MS); + const targetKey = `${developerKey}:${toUtcDayKey(target)}`; + targetKeySets[offset].add(targetKey); + if (!minTarget || target < minTarget) minTarget = target; + if (!maxTarget || target > maxTarget) maxTarget = target; + } + } + + const activities = await DeveloperActivity.find({ + developerId: { $in: signups.map((s) => s._id) }, + date: { $gte: minTarget, $lt: new Date(maxTarget.getTime() + DAY_MS) }, + }) + .select('developerId date') + .lean(); + + const activeKeySet = new Set( + activities.map((activity) => `${activity.developerId.toString()}:${toUtcDayKey(activity.date)}`), + ); + + const countRetained = (offset) => { + let retained = 0; + for (const key of targetKeySets[offset]) { + if (activeKeySet.has(key)) retained++; + } + return retained; + }; + + const d1 = countRetained(1); + const d7 = countRetained(7); + const d30 = countRetained(30); + + return new ApiResponse({ + month, + cohortSize, + d1, + d7, + d30, + d1Pct: Math.round((d1 / cohortSize) * 100), + d7Pct: Math.round((d7 / cohortSize) * 100), + d30Pct: Math.round((d30 / cohortSize) * 100), + }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/feature-usage +// Platform-wide feature breakdown from DeveloperActivity (last 30 days). +// --------------------------------------------------------------------------- +module.exports.getFeatureUsage = async (req, res, next) => { + try { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + + const agg = await DeveloperActivity.aggregate([ + { $match: { date: { $gte: thirtyDaysAgo } } }, + { + $facet: { + totals: [ + { + $group: { + _id: null, + totalApiCalls: { $sum: '$apiCallCount' }, + totalMailSent: { $sum: '$mailSentCount' }, + totalStorageUploads: { $sum: '$storageUploadsCount' }, + totalWebhooksFired: { $sum: '$webhookTriggeredCount' }, + }, + }, + ], + activeDevelopers: [ + { $group: { _id: '$developerId' } }, + { $count: 'count' }, + ], + }, + }, + ]); + + const result = agg[0]?.totals?.[0] || { + totalApiCalls: 0, + totalMailSent: 0, + totalStorageUploads: 0, + totalWebhooksFired: 0, + }; + const activeDeveloperCount = agg[0]?.activeDevelopers?.[0]?.count || 0; + + return new ApiResponse({ + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDevelopers: activeDeveloperCount, + }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/reliability +// Global error rate and latency across all projects (last 24h from ApiAnalytics). +// --------------------------------------------------------------------------- +module.exports.getReliability = async (req, res, next) => { + try { + const { ApiAnalytics } = require('@urbackend/common'); + const since = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const agg = await ApiAnalytics.aggregate([ + { $match: { timestamp: { $gte: since } } }, + { + $group: { + _id: null, + total: { $sum: 1 }, + errors: { $sum: { $cond: [{ $gte: ['$statusCode', 500] }, 1, 0] } }, + p50: { $percentile: { input: '$responseTimeMs', p: [0.5], method: 'approximate' } }, + p95: { $percentile: { input: '$responseTimeMs', p: [0.95], method: 'approximate' } }, + p99: { $percentile: { input: '$responseTimeMs', p: [0.99], method: 'approximate' } }, + }, + }, + ]); + + const r = agg[0] || { total: 0, errors: 0, p50: [0], p95: [0], p99: [0] }; + + return new ApiResponse({ + window: '24h', + totalRequests: r.total, + errorCount: r.errors, + errorRate: r.total > 0 ? ((r.errors / r.total) * 100).toFixed(2) : '0.00', + p50Ms: r.p50?.[0]?.toFixed(1) ?? null, + p95Ms: r.p95?.[0]?.toFixed(1) ?? null, + p99Ms: r.p99?.[0]?.toFixed(1) ?? null, + }).send(res); + } catch (err) { + console.error('Admin metrics error:', err); + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/top-projects +// Most active projects by API calls in the last 7 days. +// --------------------------------------------------------------------------- +module.exports.getTopProjects = async (req, res, next) => { + try { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 7); + + const agg = await Log.aggregate([ + { $match: { timestamp: { $gte: sevenDaysAgo } } }, + { $group: { _id: '$projectId', callCount: { $sum: 1 } } }, + { $sort: { callCount: -1 } }, + { $limit: 20 }, + { + $lookup: { + from: 'projects', + localField: '_id', + foreignField: '_id', + as: 'project', + }, + }, + { + $project: { + _id: 0, + projectId: '$_id', + callCount: 1, + projectName: { $arrayElemAt: ['$project.name', 0] }, + }, + }, + ]); + + return new ApiResponse({ projects: agg }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; + +// --------------------------------------------------------------------------- +// GET /api/admin/metrics/churn-signals +// Projects with zero API calls in the last 14 days that had prior activity. +// --------------------------------------------------------------------------- +module.exports.getChurnSignals = async (req, res, next) => { + try { + const fourteenDaysAgo = new Date(); + fourteenDaysAgo.setUTCDate(fourteenDaysAgo.getUTCDate() - 14); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30); + + // Projects active in 30–14d window + const prevActive = await Log.distinct('projectId', { + timestamp: { $gte: thirtyDaysAgo, $lt: fourteenDaysAgo }, + }); + + // Projects that were active but have ZERO calls in last 14d + const recentlyActive = await Log.distinct('projectId', { + timestamp: { $gte: fourteenDaysAgo }, + }); + const recentSet = new Set(recentlyActive.map(String)); + + const churnedIds = prevActive.filter((id) => !recentSet.has(String(id))); + + const projects = await Project.find({ _id: { $in: churnedIds } }) + .select('name owner createdAt') + .populate('owner', 'email') + .limit(50) + .lean(); + + return new ApiResponse({ churnSignals: churnedIds.length, projects }).send(res); + } catch (err) { + return next(new AppError(500, 'Internal server error')); + } +}; diff --git a/apps/dashboard-api/src/controllers/ai.controller.js b/apps/dashboard-api/src/controllers/ai.controller.js index e31805e8a..3f6cf03be 100644 --- a/apps/dashboard-api/src/controllers/ai.controller.js +++ b/apps/dashboard-api/src/controllers/ai.controller.js @@ -1,6 +1,6 @@ const { Project } = require('@urbackend/common/src/models'); const { forwardToPythonService } = require('../utils/internalPythonClient'); -const { AppError } = require('@urbackend/common'); +const { AppError, ApiResponse } = require('@urbackend/common'); /** * Controller to handle AI Query Builder requests. @@ -87,14 +87,10 @@ const queryBuilder = async (req, res, next) => { ); }); - res.status(200).json({ - success: true, - data: { - filters: safeFilters, - sort: typeof aiResponse.sort === 'string' ? aiResponse.sort : '-createdAt' - }, - message: "Query built successfully" - }); + return new ApiResponse({ + filters: safeFilters, + sort: typeof aiResponse.sort === 'string' ? aiResponse.sort : '-createdAt' + }, "Query built successfully").send(res); } catch (error) { // Forward expected AppErrors diff --git a/apps/dashboard-api/src/controllers/analytics.controller.js b/apps/dashboard-api/src/controllers/analytics.controller.js index c46eb8e86..3689379d1 100644 --- a/apps/dashboard-api/src/controllers/analytics.controller.js +++ b/apps/dashboard-api/src/controllers/analytics.controller.js @@ -1,10 +1,10 @@ -const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity } = require("@urbackend/common"); +const { Project, Log, Developer, Webhook, getConnection, resolveEffectivePlan, getPlanLimits, PlatformEvent, DeveloperActivity, AppError, ApiResponse } = require("@urbackend/common"); const mongoose = require("mongoose"); /** * Aggregates global usage metrics across all user projects. */ -module.exports.getGlobalStats = async (req, res) => { +module.exports.getGlobalStats = async (req, res, next) => { try { const user_id = req.user._id; const userId = new mongoose.Types.ObjectId(user_id); @@ -65,34 +65,29 @@ module.exports.getGlobalStats = async (req, res) => { } }); - res.json({ - success: true, - data: { - plan: effectivePlan, - planExpiresAt: dev?.planExpiresAt || null, - limits, - usage: { - totalProjects: globalStats.totalProjects, - totalCollections: globalStats.totalCollections, - totalStorageUsed: globalStats.totalStorageUsed, - totalDatabaseUsed: globalStats.totalDatabaseUsed, - totalRequests, - totalWebhooks, - totalUsers - } - }, - message: "" - }); + return new ApiResponse({ + plan: effectivePlan, + planExpiresAt: dev?.planExpiresAt || null, + limits, + usage: { + totalProjects: globalStats.totalProjects, + totalCollections: globalStats.totalCollections, + totalStorageUsed: globalStats.totalStorageUsed, + totalDatabaseUsed: globalStats.totalDatabaseUsed, + totalRequests, + totalWebhooks, + totalUsers + } + }).send(res); } catch (err) { - console.error('[analytics] getGlobalStats error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; /** * Fetches the most recent activity across all user projects. */ -module.exports.getRecentActivity = async (req, res) => { +module.exports.getRecentActivity = async (req, res, next) => { try { const userId = req.user._id; const projectIds = await Project.find({ owner: userId }).distinct("_id"); @@ -113,10 +108,9 @@ module.exports.getRecentActivity = async (req, res) => { timestamp: log.timestamp })); - res.json(formattedLogs); + return new ApiResponse(formattedLogs).send(res); } catch (err) { - console.error('[analytics] getRecentActivity error:', err); - res.status(500).json({ error: 'Internal server error' }); + next(err); } }; @@ -124,7 +118,7 @@ module.exports.getRecentActivity = async (req, res) => { // ACTIVATION FUNNEL // Returns step-by-step conversion rates for the current developer. // --------------------------------------------------------------------------- -module.exports.getActivationFunnel = async (req, res) => { +module.exports.getActivationFunnel = async (req, res, next) => { try { const developerId = req.user._id; @@ -157,10 +151,9 @@ module.exports.getActivationFunnel = async (req, res) => { completedAt: completed[step] || null, })); - return res.json({ success: true, data: { steps }, message: '' }); + return new ApiResponse({ steps }).send(res); } catch (err) { - console.error('[analytics] getActivationFunnel error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -168,7 +161,7 @@ module.exports.getActivationFunnel = async (req, res) => { // RETENTION (D1 / D7 / D30) // Checks whether the developer was active on Day+1, Day+7, Day+30 after signup. // --------------------------------------------------------------------------- -module.exports.getRetention = async (req, res) => { +module.exports.getRetention = async (req, res, next) => { try { const developerId = req.user._id; @@ -179,11 +172,7 @@ module.exports.getRetention = async (req, res) => { }).sort({ timestamp: 1 }).lean(); if (!signupEvent) { - return res.json({ - success: true, - data: { d1: false, d7: false, d30: false, signupDate: null }, - message: '', - }); + return new ApiResponse({ d1: false, d7: false, d30: false, signupDate: null }).send(res); } const signupDate = new Date(signupEvent.timestamp); @@ -208,14 +197,9 @@ module.exports.getRetention = async (req, res) => { checkDay(30), ]); - return res.json({ - success: true, - data: { d1, d7, d30, signupDate }, - message: '', - }); + return new ApiResponse({ d1, d7, d30, signupDate }).send(res); } catch (err) { - console.error('[analytics] getRetention error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -223,7 +207,7 @@ module.exports.getRetention = async (req, res) => { // FEATURE ENGAGEMENT (trailing 30 days) // Returns per-feature usage totals across all projects for the developer. // --------------------------------------------------------------------------- -module.exports.getEngagement = async (req, res) => { +module.exports.getEngagement = async (req, res, next) => { try { const developerId = req.user._id; @@ -262,22 +246,17 @@ module.exports.getEngagement = async (req, res) => { const flatProjectIds = (result.allProjectIds || []).flat(); const uniqueActiveProjects = new Set(flatProjectIds.map(String)).size; - return res.json({ - success: true, - data: { - window: '30d', - totalApiCalls: result.totalApiCalls, - totalMailSent: result.totalMailSent, - totalStorageUploads: result.totalStorageUploads, - totalWebhooksFired: result.totalWebhooksFired, - activeDays: result.activeDays, - uniqueActiveProjects, - }, - message: '', - }); + return new ApiResponse({ + window: '30d', + totalApiCalls: result.totalApiCalls, + totalMailSent: result.totalMailSent, + totalStorageUploads: result.totalStorageUploads, + totalWebhooksFired: result.totalWebhooksFired, + activeDays: result.activeDays, + uniqueActiveProjects, + }).send(res); } catch (err) { - console.error('[analytics] getEngagement error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; @@ -285,7 +264,7 @@ module.exports.getEngagement = async (req, res) => { // NORTH STAR METRIC // "Projects making successful API calls in the last 7 days" // --------------------------------------------------------------------------- -module.exports.getNorthStar = async (req, res) => { +module.exports.getNorthStar = async (req, res, next) => { try { const developerId = req.user._id; @@ -298,11 +277,7 @@ module.exports.getNorthStar = async (req, res) => { const totalProjects = projectIds.length; if (totalProjects === 0) { - return res.json({ - success: true, - data: { activeProjects: 0, totalProjects: 0, percentage: 0 }, - message: '', - }); + return new ApiResponse({ activeProjects: 0, totalProjects: 0, percentage: 0 }).send(res); } // Projects with at least one 2xx log in the last 7 days @@ -315,13 +290,8 @@ module.exports.getNorthStar = async (req, res) => { const activeProjects = activeProjectIds.length; const percentage = totalProjects > 0 ? Math.round((activeProjects / totalProjects) * 100) : 0; - return res.json({ - success: true, - data: { activeProjects, totalProjects, percentage }, - message: '', - }); + return new ApiResponse({ activeProjects, totalProjects, percentage }).send(res); } catch (err) { - console.error('[analytics] getNorthStar error:', err); - res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/auth.controller.js b/apps/dashboard-api/src/controllers/auth.controller.js index 88e48d1b5..3ca4b2b38 100644 --- a/apps/dashboard-api/src/controllers/auth.controller.js +++ b/apps/dashboard-api/src/controllers/auth.controller.js @@ -13,7 +13,8 @@ const { onlyEmailSchema, resetPasswordSchema, verifyOtpSchema, - AppError + AppError, + ApiResponse } = require("@urbackend/common"); const { emitEvent } = require('../utils/emitEvent'); @@ -108,13 +109,13 @@ const fetchJson = async (url, options, defaultMessage) => { payload?.error || payload?.message || defaultMessage; - throw new AppError(message, response.status || 502); + throw new AppError(response.status || 502, message); } return payload; } catch (err) { if (err.name === 'AbortError') { - throw new AppError('OAuth request timed out.', 504); + throw new AppError(504, 'OAuth request timed out.'); } throw err; } finally { @@ -160,7 +161,7 @@ const fetchGithubProfile = async (accessToken) => { : null; if (!primaryEmail?.email || !primaryEmail.verified) { - throw new Error('GitHub account must have a verified email address.'); + throw new AppError(422, 'GitHub account must have a verified email address.'); } return { @@ -236,8 +237,7 @@ const issueDashboardSession = async (user, res) => { const sendTokenResponse = async (user, statusCode, res) => { await issueDashboardSession(user, res); - return res.status(statusCode).json({ - success: true, + return new ApiResponse({ user: { _id: user._id, email: user.email, @@ -245,7 +245,7 @@ const sendTokenResponse = async (user, statusCode, res) => { maxProjects: user.maxProjects, isAdmin: user.email === process.env.ADMIN_EMAIL } - }); + }).send(res, statusCode); }; async function createAndStoreOtp(userId) { @@ -262,11 +262,11 @@ async function createAndStoreOtp(userId) { async function validateOtp(userId, passedOtp) { const otpDoc = await Otp.findOne({ userId }); - if (!otpDoc) throw { status: 400, message: "No OTP found. Please request a new one." }; + if (!otpDoc) throw new AppError(400, "No OTP found. Please request a new one."); if (otpDoc.attempts >= OTP_MAX_ATTEMPTS) { await otpDoc.deleteOne(); - throw { status: 429, message: "Too many incorrect attempts. Please request a new OTP." }; + throw new AppError(429, "Too many incorrect attempts. Please request a new OTP."); } const isMatch = await bcrypt.compare(passedOtp.toString(), otpDoc.otp); @@ -274,7 +274,7 @@ async function validateOtp(userId, passedOtp) { otpDoc.attempts += 1; await otpDoc.save(); const remaining = OTP_MAX_ATTEMPTS - otpDoc.attempts; - throw { status: 400, message: `Incorrect OTP. ${remaining} attempt(s) remaining.` }; + throw new AppError(400, `Incorrect OTP. ${remaining} attempt(s) remaining.`); } return otpDoc; @@ -286,17 +286,17 @@ async function checkOtpCooldown(userId) { const secondsSinceCreated = (Date.now() - existingOtp.createdAt.getTime()) / 1000; if (secondsSinceCreated < 60) { const waitTime = Math.ceil(60 - secondsSinceCreated); - throw { status: 429, message: `Please wait ${waitTime} seconds before requesting a new OTP.` }; + throw new AppError(429, `Please wait ${waitTime} seconds before requesting a new OTP.`); } } } -module.exports.register = async (req, res) => { +module.exports.register = async (req, res, next) => { try { const { email, password } = loginSchema.parse(req.body); const existingUser = await Developer.findOne({ email }); - if (existingUser) return res.status(400).json({ error: "Email already exists" }); + if (existingUser) return next(new AppError(400, "Email already exists")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(password, salt); @@ -307,41 +307,36 @@ module.exports.register = async (req, res) => { // Activation funnel — signup completed emitEvent(newDev._id, 'signup_completed', { method: 'email' }); - res.status(201).json({ message: "Registered successfully" }); + return new ApiResponse({}, "Registered successfully").send(res, 201); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.login = async (req, res) => { +module.exports.login = async (req, res, next) => { try { const { email, password } = loginSchema.parse(req.body); const dev = await Developer.findOne({ email: email.toLowerCase().trim() }).select('+password'); - if (!dev) return res.status(400).json({ error: "User not found" }); + if (!dev) return next(new AppError(400, "User not found")); const validPass = await bcrypt.compare(password, dev.password); - if (!validPass) return res.status(400).json({ error: "Invalid password" }); + if (!validPass) return next(new AppError(400, "Invalid password")); await sendTokenResponse(dev, 200, res); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ - error: "Validation Failed", - details: err.issues - }); + return next(new AppError(400, err.issues[0]?.message || 'Validation Failed')); } - console.error("Server Error:", err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } } -module.exports.startGithubAuth = async (req, res) => { +module.exports.startGithubAuth = async (req, res, next) => { if (!process.env.DASHBOARD_GITHUB_CLIENT_ID || !process.env.DASHBOARD_GITHUB_CLIENT_SECRET) { - return res.status(503).json({ error: 'GitHub login is not configured.' }); + return next(new AppError(503, 'GitHub login is not configured.')); } const state = crypto.randomBytes(24).toString('hex'); @@ -406,14 +401,15 @@ module.exports.handleGithubCallback = async (req, res) => { }; -module.exports.changePassword = async (req, res) => { +module.exports.changePassword = async (req, res, next) => { try { const { currentPassword, newPassword } = changePasswordSchema.parse(req.body); const dev = await Developer.findById(req.user._id).select('+password'); + if (!dev) return next(new AppError(404, "User not found")); const validPass = await bcrypt.compare(currentPassword, dev.password); - if (!validPass) return res.status(400).json({ error: "Incorrect current password" }); + if (!validPass) return next(new AppError(400, "Incorrect current password")); const salt = await bcrypt.genSalt(10); const hashedPassword = await bcrypt.hash(newPassword, salt); @@ -421,87 +417,72 @@ module.exports.changePassword = async (req, res) => { dev.password = hashedPassword; await dev.save(); - res.json({ message: "Password updated successfully" }); + return new ApiResponse({}, "Password updated successfully").send(res); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.deleteAccount = async (req, res) => { +module.exports.deleteAccount = async (req, res, next) => { try { const { password } = deleteAccountSchema.parse(req.body); const dev = await Developer.findById(req.user._id).select('+password'); + if (!dev) return next(new AppError(404, "User not found")); const validPass = await bcrypt.compare(password, dev.password); - if (!validPass) return res.status(400).json({ error: "Incorrect password. Cannot delete account." }); + if (!validPass) return next(new AppError(400, "Incorrect password. Cannot delete account.")); await Project.deleteMany({ owner: req.user._id }); await Developer.findByIdAndDelete(req.user._id); - res.json({ message: "Account and all projects deleted." }); + return new ApiResponse({}, "Account and all projects deleted.").send(res); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } -module.exports.sendOtp = async (req, res) => { +module.exports.sendOtp = async (req, res, next) => { try { const { email } = onlyEmailSchema.parse(req.body); const normalizedEmail = email.toLowerCase().trim(); const existingUser = await Developer.findOne({ email: normalizedEmail }); if (!existingUser) { - return res.status(400).json({ error: "User not found. Ensure you are using the correct email." }); + return next(new AppError(400, "User not found. Ensure you are using the correct email.")); } if (existingUser.isVerified) { - return res.status(400).json({ error: "Account is already verified. Please login." }); + return next(new AppError(400, "Account is already verified. Please login.")); } - // Check 60s cooldown - try { - await checkOtpCooldown(existingUser._id); - } catch (cooldownErr) { - return res.status(cooldownErr.status || 429).json({ error: cooldownErr.message }); - } + // Check 60s cooldown — checkOtpCooldown now throws AppError directly + await checkOtpCooldown(existingUser._id); const otp = await createAndStoreOtp(existingUser._id); await sendOtp(email, otp); // Send raw OTP to user's email - res.json({ message: "OTP sent successfully" }); + return new ApiResponse({}, "OTP sent successfully").send(res); } catch (err) { if (err instanceof z.ZodError) { - return res.status(400).json({ - error: "Invalid email format", - details: err.errors - }); + return next(new AppError(400, 'Invalid email format')); } - - console.error("🔥 Dashboard OTP Send Error:", { - email: req.body?.email, - error: err.message, - stack: err.stack - }); - - res.status(500).json({ error: "Failed to send OTP. Please try again later." }); + next(err); } } -module.exports.verifyOtp = async (req, res) => { +module.exports.verifyOtp = async (req, res, next) => { try { const { email, otp } = verifyOtpSchema.parse(req.body); const existingUser = await Developer.findOne({ email }).select('+password'); - if (!existingUser) return res.status(400).json({ error: "User not found" }); + if (!existingUser) return next(new AppError(400, "User not found")); const otpDoc = await validateOtp(existingUser._id, otp); @@ -514,41 +495,38 @@ module.exports.verifyOtp = async (req, res) => { await sendTokenResponse(existingUser, 200, res); } catch (err) { - if (err.status) return res.status(err.status).json({ error: err.message }); - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // FORGOT PASSWORD -module.exports.forgotPassword = async (req, res) => { +module.exports.forgotPassword = async (req, res, next) => { try { const { email } = onlyEmailSchema.parse(req.body); const dev = await Developer.findOne({ email }); - if (!dev) return res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); + if (!dev) return new ApiResponse({}, "If this email is registered, an OTP has been sent.").send(res, 200); const otp = await createAndStoreOtp(dev._id); await sendOtp(email, otp, { subject: "Password Reset OTP \u2014 urBackend" }); - res.status(200).json({ message: "If this email is registered, an OTP has been sent." }); + return new ApiResponse({}, "If this email is registered, an OTP has been sent.").send(res, 200); } catch (err) { - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // RESET PASSWORD -module.exports.resetPassword = async (req, res) => { +module.exports.resetPassword = async (req, res, next) => { try { const { email, otp, newPassword } = resetPasswordSchema.parse(req.body); const dev = await Developer.findOne({ email }).select('+password'); - if (!dev) return res.status(400).json({ error: "User not found" }); + if (!dev) return next(new AppError(400, "User not found")); const otpDoc = await validateOtp(dev._id, otp); @@ -559,31 +537,28 @@ module.exports.resetPassword = async (req, res) => { dev.refreshToken = null; await dev.save(); - res - .status(200) - .cookie('accessToken', 'none', { - expires: new Date(Date.now() + 10 * 1000), - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' - }) - .cookie('refreshToken', 'none', { - expires: new Date(Date.now() + 10 * 1000), - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax' - }) - .json({ message: "Password reset successfully. Please log in with your new password." }); + res.cookie('accessToken', 'none', { + expires: new Date(Date.now() + 10 * 1000), + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }); + res.cookie('refreshToken', 'none', { + expires: new Date(Date.now() + 10 * 1000), + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }); + + return new ApiResponse({}, "Password reset successfully. Please log in with your new password.").send(res, 200); } catch (err) { - if (err.status) return res.status(err.status).json({ error: err.message }); - if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors }); - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + if (err instanceof z.ZodError) return next(new AppError(400, err.issues?.[0]?.message || 'Validation failed')); + next(err); } } // LOGOUT -module.exports.logout = async (req, res) => { +module.exports.logout = async (req, res, next) => { try { if (req.user) { const user = await Developer.findById(req.user._id); @@ -606,45 +581,46 @@ module.exports.logout = async (req, res) => { sameSite: 'lax' }); - res.status(200).json({ success: true, message: "Logged out successfully" }); + return new ApiResponse({}, "Logged out successfully").send(res, 200); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; // REFRESH TOKEN -module.exports.refreshToken = async (req, res) => { +module.exports.refreshToken = async (req, res, next) => { try { const refreshToken = req.cookies.refreshToken; if (!refreshToken) { - return res.status(401).json({ error: "No refresh token provided" }); + return next(new AppError(401, "No refresh token provided")); } const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET); const user = await Developer.findById(decoded._id).select('+refreshToken'); if (!user || user.refreshToken !== refreshToken) { - return res.status(403).json({ error: "Invalid refresh token" }); + return next(new AppError(403, "Invalid refresh token")); } await sendTokenResponse(user, 200, res); } catch (err) { - res.status(403).json({ error: "Invalid or expired refresh token" }); + if (err.name === 'TokenExpiredError' || err.name === 'JsonWebTokenError') { + return next(new AppError(403, "Invalid or expired refresh token")); + } + next(err); } }; // GET ME -module.exports.getMe = async (req, res) => { +module.exports.getMe = async (req, res, next) => { try { const user = await Developer.findById(req.user._id).select("-password -refreshToken"); - if (!user) return res.status(404).json({ error: "User not found" }); + if (!user) return next(new AppError(404, "User not found")); const userData = typeof user.toObject === 'function' ? user.toObject() : { ...user }; userData.isAdmin = userData.email === process.env.ADMIN_EMAIL; - res.json({ success: true, user: userData }); + return new ApiResponse({ user: userData }).send(res); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/billing.controller.js b/apps/dashboard-api/src/controllers/billing.controller.js index ab1ace87b..474695777 100644 --- a/apps/dashboard-api/src/controllers/billing.controller.js +++ b/apps/dashboard-api/src/controllers/billing.controller.js @@ -1,6 +1,6 @@ const Razorpay = require('razorpay'); const crypto = require('crypto'); -const { Developer, ProRequest, AppError, sendProRequestConfirmationEmail, sanitizeNonEmptyString, sanitizeObjectId } = require('@urbackend/common'); +const { Developer, ProRequest, AppError, ApiResponse, sendProRequestConfirmationEmail, sanitizeNonEmptyString, sanitizeObjectId } = require('@urbackend/common'); const getRazorpayInstance = () => { const keyId = process.env.RAZORPAY_KEY_ID; @@ -24,6 +24,7 @@ module.exports.createCheckout = async (req, res, next) => { // ------------------------------------------------------------------------------------------------- return next(new AppError(403, 'Automatic payments are disabled during Public Beta. Please use the Request Pro form.')); + /* try { const planId = process.env.RAZORPAY_PLAN_ID; if (!planId) { @@ -50,19 +51,16 @@ module.exports.createCheckout = async (req, res, next) => { }, }); - res.json({ - success: true, - data: { - subscriptionId: subscription.id, - keyId: process.env.RAZORPAY_KEY_ID - }, - message: '' - }); + return new ApiResponse({ + subscriptionId: subscription.id, + keyId: process.env.RAZORPAY_KEY_ID + }).send(res); } catch (err) { if (err instanceof AppError) return next(err); console.error('Razorpay checkout error:', err); return next(new AppError(502, 'Failed to create checkout session. Please try again.')); - } + } + */ }; /** @@ -89,11 +87,7 @@ module.exports.createProRequest = async (req, res, next) => { // Send confirmation email sendProRequestConfirmationEmail(cleanEmail).catch(err => console.error("Failed to send Pro request email:", err)); - res.json({ - success: true, - data: request, - message: 'Your Pro request has been submitted successfully.' - }); + return new ApiResponse(request, 'Your Pro request has been submitted successfully.').send(res); } catch (err) { if (err instanceof AppError) return next(err); console.error('Pro request error:', err); @@ -110,11 +104,7 @@ module.exports.getProRequests = async (req, res, next) => { if (!req.user?.isAdmin) return next(new AppError(403, 'Forbidden')); const requests = await ProRequest.find().sort({ createdAt: -1 }); - res.json({ - success: true, - data: requests, - message: '' - }); + return new ApiResponse(requests).send(res); } catch (err) { console.error('Get Pro requests error:', err); return next(new AppError(500, 'Failed to fetch Pro requests.')); @@ -153,11 +143,7 @@ module.exports.approveProRequest = async (req, res, next) => { request.status = 'approved'; await request.save(); - res.json({ - success: true, - data: { developer, request }, - message: `Successfully upgraded ${developer.email} to Pro.` - }); + return new ApiResponse({ developer, request }, `Successfully upgraded ${developer.email} to Pro.`).send(res); } catch (err) { console.error('Approve Pro request error:', err); return next(new AppError(500, 'Failed to approve Pro request.')); @@ -182,7 +168,7 @@ module.exports.handleWebhook = async (req, res, next) => { if (webhookSecret) { const signature = req.headers['x-razorpay-signature']; if (!signature) { - return res.status(401).json({ success: false, message: 'Missing webhook signature.' }); + return next(new AppError(401, 'Missing webhook signature.')); } const rawBody = req.rawBody || JSON.stringify(req.body); @@ -192,7 +178,7 @@ module.exports.handleWebhook = async (req, res, next) => { .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { - return res.status(401).json({ success: false, message: 'Invalid webhook signature.' }); + return next(new AppError(401, 'Invalid webhook signature.')); } } @@ -200,20 +186,20 @@ module.exports.handleWebhook = async (req, res, next) => { const payload = req.body.payload?.subscription?.entity; if (!payload) { - return res.json({ success: true, message: 'No subscription payload. Skipped.' }); + return new ApiResponse({}, 'No subscription payload. Skipped.').send(res); } // Extract developer_id from subscription notes const developerId = payload.notes?.developer_id; if (!developerId || typeof developerId !== 'string') { console.warn('Razorpay webhook: no valid developer_id in notes'); - return res.json({ success: true, message: 'No valid developer_id in notes. Skipped.' }); + return new ApiResponse({}, 'No valid developer_id in notes. Skipped.').send(res); } const developer = await Developer.findById(developerId); if (!developer) { console.warn(`Razorpay webhook: developer not found for id ${developerId}`); - return res.json({ success: true, message: 'Developer not found. Skipped.' }); + return new ApiResponse({}, 'Developer not found. Skipped.').send(res); } const now = new Date(); @@ -237,10 +223,10 @@ module.exports.handleWebhook = async (req, res, next) => { } // Always return 200 to acknowledge - res.json({ success: true, message: 'Webhook processed.' }); + return new ApiResponse({}, 'Webhook processed.').send(res); } catch (err) { console.error('Billing webhook error:', err); // Return 200 to avoid Razorpay retry storms - res.json({ success: true, message: 'Internal error. Logged.' }); + return new ApiResponse({}, 'Internal error. Logged.').send(res); } }; diff --git a/apps/dashboard-api/src/controllers/dbExport.controller.js b/apps/dashboard-api/src/controllers/dbExport.controller.js index 09230c80a..ec7d52bf7 100644 --- a/apps/dashboard-api/src/controllers/dbExport.controller.js +++ b/apps/dashboard-api/src/controllers/dbExport.controller.js @@ -1,4 +1,4 @@ -const { AppError } = require('@urbackend/common'); +const { AppError, ApiResponse } = require('@urbackend/common'); const { Developer } = require('@urbackend/common'); const { Project } = require('@urbackend/common'); const { exportQueue } = require('@urbackend/common'); @@ -53,11 +53,7 @@ module.exports.dbExportHandler = async (req, res, next) => { await exportQueue.add('export-database', { projectId, collectionName, userId, email }); - return res.status(202).json({ - success: true, - data: {}, - message: `Collection export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`, - }); + return new ApiResponse({}, `Collection export request received. You will receive an email with a download link shortly. Usage today: ${newCount}/${maxExports}.`).send(res, 202); } catch (err) { console.error("[Dashboard API] Error handling export request for project - ", req.params.projectId, ": ", err); diff --git a/apps/dashboard-api/src/controllers/events.controller.js b/apps/dashboard-api/src/controllers/events.controller.js index bf7d7c508..8f98bd758 100644 --- a/apps/dashboard-api/src/controllers/events.controller.js +++ b/apps/dashboard-api/src/controllers/events.controller.js @@ -1,3 +1,4 @@ +const { AppError , ApiResponse} = require('@urbackend/common'); const { emitEvent } = require('../utils/emitEvent'); // Allowed frontend-emitted event names (whitelist prevents garbage in DB) @@ -18,22 +19,18 @@ const ALLOWED_FRONTEND_EVENTS = new Set([ * Receives frontend-emitted tracking events and writes them as PlatformEvents. * Only whitelisted event names are accepted to prevent junk data. */ -module.exports.track = async (req, res) => { +module.exports.track = async (req, res, next) => { try { const { event, properties = {}, projectId } = req.body; if (!event || typeof event !== 'string') { - return res.status(400).json({ success: false, data: {}, message: 'event name is required' }); + return next(new AppError(400, 'event name is required')); } const normalizedEvent = event.trim().toLowerCase().replace(/\s+/g, '_'); if (!ALLOWED_FRONTEND_EVENTS.has(normalizedEvent)) { - return res.status(400).json({ - success: false, - data: {}, - message: `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`, - }); + return next(new AppError(400, `Unknown event: "${normalizedEvent}". Allowed: ${[...ALLOWED_FRONTEND_EVENTS].join(', ')}`)); } // emitEvent is fire-and-forget — responds 200 immediately @@ -44,9 +41,9 @@ module.exports.track = async (req, res) => { projectId || null, ); - return res.json({ success: true, data: {}, message: 'Event queued' }); + return new ApiResponse({} , "Event queued").send(res) } catch (err) { - console.error('[events.controller] track error:', err); - return res.status(500).json({ success: false, data: {}, message: 'Internal server error' }); + const forwardedError = err instanceof AppError ? err : new AppError(500, 'Internal server error'); + next(forwardedError); } }; diff --git a/apps/dashboard-api/src/controllers/project.controller.js b/apps/dashboard-api/src/controllers/project.controller.js index a110f8c58..493c3deec 100644 --- a/apps/dashboard-api/src/controllers/project.controller.js +++ b/apps/dashboard-api/src/controllers/project.controller.js @@ -1224,7 +1224,6 @@ module.exports.recoverRow = async (req, res, next) => { res.json({ success: true, data: result, message: "Document recovered from trash" }); } catch (err) { - console.error("Recover Error:", err); if (err && err.code === 11000) { return next(new AppError(409, "Cannot restore document: a unique field value conflicts with an existing active document.")); } diff --git a/apps/dashboard-api/src/controllers/release.controller.js b/apps/dashboard-api/src/controllers/release.controller.js index a121b5e60..6b0ef0ab3 100644 --- a/apps/dashboard-api/src/controllers/release.controller.js +++ b/apps/dashboard-api/src/controllers/release.controller.js @@ -1,6 +1,7 @@ const {Release} = require("@urbackend/common"); const {Developer} = require("@urbackend/common"); const { emailQueue } = require("@urbackend/common"); +const { AppError, ApiResponse } = require("@urbackend/common"); const ADMIN_EMAIL = process.env.ADMIN_EMAIL; @@ -65,29 +66,29 @@ const extractReleaseLinkFromContent = (content) => { }; // GET FOR - ALL RELEASES -exports.getAllReleases = async (req, res) => { +exports.getAllReleases = async (req, res, next) => { try { const releases = await Release.find().sort({ createdAt: -1 }); - res.json(releases); + return new ApiResponse(releases).send(res); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + const forwardedError = err instanceof AppError ? err : new AppError(500, "Internal server error"); + next(forwardedError); } }; // POST FOR - CREATE RELEASE -exports.createRelease = async (req, res) => { +exports.createRelease = async (req, res, next) => { try { const { version, title, content } = req.body; const changelogUrlFromContent = extractReleaseLinkFromContent(content); const dev = await Developer.findById(req.user._id); if (!dev || dev.email !== ADMIN_EMAIL) { - return res.status(403).json({ error: "Access denied. Admin only." }); + return next(new AppError(403, "Access denied. Admin only.")); } if (!version || !title || !content) { - return res.status(400).json({ error: "Missing version, title, or content" }); + return next(new AppError(400, "Missing version, title, or content")); } const newRelease = new Release({ @@ -111,13 +112,10 @@ exports.createRelease = async (req, res) => { }) )); - res.status(201).json({ - message: "Release published! Emails queued.", - count: emails.length - }); + return new ApiResponse({ count: emails.length }, "Release published! Emails queued.").send(res, 201); } catch (err) { - console.error(err); - res.status(500).json({ error: "Internal Server Error" }); + const forwardedError = err instanceof AppError ? err : new AppError(500, "Internal server error"); + next(forwardedError); } }; diff --git a/apps/dashboard-api/src/controllers/userAuth.controller.js b/apps/dashboard-api/src/controllers/userAuth.controller.js index 8a1c0f7a1..e09e2c603 100644 --- a/apps/dashboard-api/src/controllers/userAuth.controller.js +++ b/apps/dashboard-api/src/controllers/userAuth.controller.js @@ -2,18 +2,14 @@ const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const { z } = require('zod'); const mongoose = require('mongoose'); -const {Project} = require('@urbackend/common'); -const {redis} = require('@urbackend/common'); -const { authEmailQueue } = require('@urbackend/common'); -const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common'); -const { getConnection } = require('@urbackend/common'); -const { getCompiledModel } = require('@urbackend/common'); const { - AppError, - ApiResponse, - getUserActiveSessions, - getRefreshSession, - revokeSessionChain + Project, + redis, + authEmailQueue, + loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize, AppError, ApiResponse, + getConnection, + getCompiledModel, + getUserActiveSessions, getRefreshSession, revokeSessionChain } = require('@urbackend/common'); const hasRequiredField = (usersColConfig, fieldKey) => { @@ -67,7 +63,7 @@ const buildAuthUserPayload = (usersColConfig, parsedData, hashedPassword, verifi // POST REQ FOR SIGNUP -module.exports.signup = async (req, res) => { +module.exports.signup = async (req, res, next) => { try { const project = req.project; @@ -75,14 +71,14 @@ 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); 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); @@ -115,38 +111,33 @@ module.exports.signup = async (req, res) => { { expiresIn: '7d' } ); - res.status(201).json({ - message: "User registered successfully. Please verify your email.", - token: token, - userId: result._id - }); + return new ApiResponse({ token: token, userId: result._id }, "User registered successfully. Please verify your email.").send(res, 201); } 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 || "Validation failed")); } - res.status(500).json({ error: err.message }); - console.log(err) + next(err); } } // POST REQ FOR LOGIN -module.exports.login = async (req, res) => { +module.exports.login = async (req, res, next) => { try { const project = req.project; const { email, password } = loginSchema.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({ email }); - if (!user) return res.status(400).json({ error: "Invalid email or password" }); + if (!user) return next(new AppError(400, "Invalid email or password")); const validPass = await bcrypt.compare(password, user.password); - if (!validPass) return res.status(400).json({ error: "Invalid email or password" }); + if (!validPass) return next(new AppError(400, "Invalid email or password")); const token = jwt.sign( { userId: user._id, projectId: project._id }, @@ -154,28 +145,28 @@ module.exports.login = async (req, res) => { { expiresIn: '7d' } ); - res.json({ token }); + return new ApiResponse({ token }).send(res); } 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.issues?.[0]?.message || 'Validation failed')); + next(err); } } // FUNCTION - GET CURRENT USER -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); @@ -185,21 +176,21 @@ 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")); - res.json(user); + return new ApiResponse({ user }).send(res); } 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 }); + next(err); } } // POST REQ FOR ADMIN CREATE USER -module.exports.createAdminUser = async (req, res) => { +module.exports.createAdminUser = async (req, res, next) => { try { const project = req.project; @@ -208,14 +199,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); @@ -230,17 +221,13 @@ module.exports.createAdminUser = async (req, res) => { const result = await Model.create(newUserPayload); - res.status(201).json({ - message: "User created successfully", - user: { _id: result._id, email, username, createdAt: newUserPayload.createdAt } - }); + return new ApiResponse({ user: { _id: result._id, email, username, createdAt: newUserPayload.createdAt } }, "User created successfully").send(res, 201); } 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 || "Validation failed")); } - res.status(500).json({ error: err.message }); + next(err); } } @@ -301,7 +288,7 @@ module.exports.deleteAdminUser = async (req, res, next) => { }; // 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; @@ -309,11 +296,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); @@ -327,18 +314,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" }); + return new ApiResponse({}, "Password updated successfully").send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } } // 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); @@ -347,50 +334,50 @@ module.exports.verifyEmail = 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 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" }); + return new ApiResponse({}, "Email verified successfully").send(res); } 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")); + next(err); } }; // 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 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({ email }); if (!user) { - return res.json({ message: "If that email exists, a reset code has been sent." }); + return new ApiResponse({}, "If that email exists, a reset code has been sent.").send(res); } const otp = Math.floor(100000 + Math.random() * 900000).toString(); @@ -398,15 +385,15 @@ module.exports.requestPasswordReset = async (req, res) => { await authEmailQueue.add('send-reset-email', { email, otp, type: 'password_reset', pname: project.name }); - res.json({ message: "If that email exists, a reset code has been sent." }); + return new ApiResponse({}, "If that email exists, a reset code has been sent.").send(res); } 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")); + next(err); } }; // 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); @@ -415,7 +402,7 @@ 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); @@ -428,36 +415,36 @@ module.exports.resetPasswordUser = async (req, res) => { { $set: { password: hashedPassword } } ); - 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: "Password updated successfully" }); + return new ApiResponse({}, "Password updated successfully").send(res); } 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")); + next(err); } }; // 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 username = req.body.username; if (!username || 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.")); } const collection = await getAuthCollection(project); @@ -467,42 +454,42 @@ module.exports.updateProfile = async (req, res) => { { $set: { username } } ); - res.json({ message: "Profile updated successfully" }); + return new ApiResponse({}, "Profile updated successfully").send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // 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 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); @@ -512,22 +499,22 @@ module.exports.changePasswordUser = async (req, res) => { { $set: { password: hashedPassword } } ); - res.json({ message: "Password changed successfully" }); + return new ApiResponse({}, "Password changed successfully").send(res); } 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")); + next(err); } }; // 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); @@ -536,16 +523,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); + return new ApiResponse({ user }).send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // 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; @@ -558,7 +545,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); @@ -570,17 +557,17 @@ 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" }); + return new ApiResponse({}, "User updated successfully").send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // GET ACTIVE SESSIONS FOR A USER (Admin) -module.exports.listUserSessions = async (req, res) => { +module.exports.listUserSessions = async (req, res, next) => { try { const project = req.project; const { userId } = req.params; @@ -595,19 +582,19 @@ module.exports.listUserSessions = async (req, res) => { { _id: 1 } ).lean(); if (!userExists) { - return res.status(404).json({ error: 'User not found in this project' }); + return next(new AppError(404, 'User not found in this project')); } } const sessions = await getUserActiveSessions(project._id, userId); - res.json({ sessions }); + return new ApiResponse({ sessions }).send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; // REVOKE A SPECIFIC SESSION FOR A USER (Admin) -module.exports.revokeUserSession = async (req, res) => { +module.exports.revokeUserSession = async (req, res, next) => { try { const projectId = String(req.project._id); const { userId, tokenId } = req.params; @@ -618,14 +605,14 @@ module.exports.revokeUserSession = async (req, res) => { if (!session || String(session.projectId) !== projectId || String(session.userId) !== String(userId)) { - return res.status(404).json({ error: 'Session not found or does not belong to this user' }); + return next(new AppError(404, 'Session not found or does not belong to this user')); } // Revoke the entire chain starting from this token, cleaning up the user sessions set await revokeSessionChain(tokenId); - res.json({ message: 'Session revoked successfully' }); + return new ApiResponse({}, 'Session revoked successfully').send(res); } catch (err) { - res.status(500).json({ error: err.message }); + next(err); } }; diff --git a/apps/dashboard-api/src/controllers/webhook.controller.js b/apps/dashboard-api/src/controllers/webhook.controller.js index 2727a744c..b2762f1fd 100644 --- a/apps/dashboard-api/src/controllers/webhook.controller.js +++ b/apps/dashboard-api/src/controllers/webhook.controller.js @@ -3,6 +3,8 @@ const { Webhook, WebhookDelivery, Project, + AppError, + ApiResponse, encrypt, decrypt, createWebhookSchema, @@ -17,12 +19,12 @@ const isValidId = (id) => mongoose.Types.ObjectId.isValid(id); /** * Create a new webhook for a project */ -module.exports.createWebhook = async (req, res) => { +module.exports.createWebhook = async (req, res, next) => { try { const { projectId } = req.params; if (!isValidId(projectId)) { - return res.status(400).json({ error: "Invalid project ID" }); + return next(new AppError(400, "Invalid project ID")); } // Verify project ownership @@ -31,16 +33,13 @@ module.exports.createWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Validate input const validation = createWebhookSchema.safeParse(req.body); if (!validation.success) { - return res.status(400).json({ - error: "Validation failed", - details: validation.error.errors, - }); + return next(new AppError(400, validation.error.issues?.[0]?.message || "Validation failed")); } const { name, url, secret, events, enabled } = validation.data; @@ -58,9 +57,7 @@ module.exports.createWebhook = async (req, res) => { }); // Return without secret - res.status(201).json({ - message: "Webhook created", - data: { + return new ApiResponse({ _id: webhook._id, projectId: webhook.projectId, name: webhook.name, @@ -68,23 +65,21 @@ module.exports.createWebhook = async (req, res) => { events: Object.fromEntries(webhook.events || new Map()), enabled: webhook.enabled, createdAt: webhook.createdAt, - }, - }); + }, "Webhook created").send(res, 201); } catch (err) { - console.error("[Webhook] Create error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get all webhooks for a project */ -module.exports.getWebhooks = async (req, res) => { +module.exports.getWebhooks = async (req, res, next) => { try { const { projectId } = req.params; if (!isValidId(projectId)) { - return res.status(400).json({ error: "Invalid project ID" }); + return next(new AppError(400, "Invalid project ID")); } // Verify project ownership @@ -93,7 +88,7 @@ module.exports.getWebhooks = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhooks = await Webhook.find({ projectId }).lean(); @@ -110,22 +105,21 @@ module.exports.getWebhooks = async (req, res) => { updatedAt: wh.updatedAt, })); - res.json({ data }); + return new ApiResponse(data).send(res); } catch (err) { - console.error("[Webhook] List error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get a single webhook */ -module.exports.getWebhook = async (req, res) => { +module.exports.getWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -134,7 +128,7 @@ module.exports.getWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhook = await Webhook.findOne({ @@ -143,36 +137,33 @@ module.exports.getWebhook = async (req, res) => { }).lean(); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } - res.json({ - data: { - _id: webhook._id, - projectId: webhook.projectId, - name: webhook.name, - url: webhook.url, - events: webhook.events || {}, - enabled: webhook.enabled, - createdAt: webhook.createdAt, - updatedAt: webhook.updatedAt, - }, - }); + return new ApiResponse({ + _id: webhook._id, + projectId: webhook.projectId, + name: webhook.name, + url: webhook.url, + events: webhook.events || {}, + enabled: webhook.enabled, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + }).send(res); } catch (err) { - console.error("[Webhook] Get error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Update a webhook */ -module.exports.updateWebhook = async (req, res) => { +module.exports.updateWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -181,16 +172,13 @@ module.exports.updateWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Validate input const validation = updateWebhookSchema.safeParse(req.body); if (!validation.success) { - return res.status(400).json({ - error: "Validation failed", - details: validation.error.errors, - }); + return next(new AppError(400, validation.error.issues?.[0]?.message || "Validation failed")); } const { name, url, secret, events, enabled } = validation.data; @@ -213,37 +201,33 @@ module.exports.updateWebhook = async (req, res) => { ).lean(); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } - res.json({ - message: "Webhook updated", - data: { - _id: webhook._id, - projectId: webhook.projectId, - name: webhook.name, - url: webhook.url, - events: webhook.events || {}, - enabled: webhook.enabled, - createdAt: webhook.createdAt, - updatedAt: webhook.updatedAt, - }, - }); + return new ApiResponse({ + _id: webhook._id, + projectId: webhook.projectId, + name: webhook.name, + url: webhook.url, + events: webhook.events || {}, + enabled: webhook.enabled, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + }, "Webhook updated").send(res); } catch (err) { - console.error("[Webhook] Update error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Delete a webhook */ -module.exports.deleteWebhook = async (req, res) => { +module.exports.deleteWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -252,7 +236,7 @@ module.exports.deleteWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } const webhook = await Webhook.findOneAndDelete({ @@ -261,29 +245,28 @@ module.exports.deleteWebhook = async (req, res) => { }); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } // Optionally clean up delivery logs (or keep for audit) // await WebhookDelivery.deleteMany({ webhookId }); - res.json({ message: "Webhook deleted" }); + return new ApiResponse({}, "Webhook deleted").send(res); } catch (err) { - console.error("[Webhook] Delete error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Get delivery history for a webhook */ -module.exports.getDeliveries = async (req, res) => { +module.exports.getDeliveries = async (req, res, next) => { try { const { projectId, webhookId } = req.params; const { limit = 50, page = 1 } = req.query; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -292,13 +275,13 @@ module.exports.getDeliveries = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Verify webhook belongs to project const webhook = await Webhook.findOne({ _id: webhookId, projectId }); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } const pageNum = Math.max(1, parseInt(page) || 1); @@ -314,30 +297,29 @@ module.exports.getDeliveries = async (req, res) => { WebhookDelivery.countDocuments({ webhookId }), ]); - res.json({ - data: deliveries, + return new ApiResponse({ + deliveries, pagination: { page: pageNum, limit: limitNum, total, totalPages: Math.ceil(total / limitNum), }, - }); + }).send(res); } catch (err) { - console.error("[Webhook] Get deliveries error:", err); - res.status(500).json({ error: err.message }); + next(err); } }; /** * Send a test webhook */ -module.exports.testWebhook = async (req, res) => { +module.exports.testWebhook = async (req, res, next) => { try { const { projectId, webhookId } = req.params; if (!isValidId(projectId) || !isValidId(webhookId)) { - return res.status(400).json({ error: "Invalid ID format" }); + return next(new AppError(400, "Invalid ID format")); } // Verify project ownership @@ -346,7 +328,7 @@ module.exports.testWebhook = async (req, res) => { owner: req.user._id, }); if (!project) { - return res.status(404).json({ error: "Project not found" }); + return next(new AppError(404, "Project not found")); } // Load webhook with secret @@ -355,7 +337,7 @@ module.exports.testWebhook = async (req, res) => { ); if (!webhook) { - return res.status(404).json({ error: "Webhook not found" }); + return next(new AppError(404, "Webhook not found")); } // Decrypt secret @@ -364,7 +346,7 @@ module.exports.testWebhook = async (req, res) => { secret = decrypt(webhook.secret); if (!secret) throw new Error("Decryption failed"); } catch (err) { - return res.status(500).json({ error: "Failed to decrypt webhook secret" }); + return next(new AppError(500, "Failed to decrypt webhook secret")); } // Create test payload @@ -423,15 +405,14 @@ module.exports.testWebhook = async (req, res) => { const durationMs = Date.now() - startTime; const success = statusCode >= 200 && statusCode < 300; - res.json({ + return new ApiResponse({ success, statusCode, responseBody, error, durationMs, - }); + }).send(res); } catch (err) { - console.error("[Webhook] Test error:", err); - res.status(500).json({ error: err.message }); + next(err); } };