diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 55d5ea2..5b3272b 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -1,6 +1,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core'; import {ActionSchemas} from '@plunk/shared'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requirePublicKey, requireSecretKey} from '../middleware/auth.js'; @@ -10,6 +10,7 @@ import {DomainService} from '../services/DomainService.js'; import {EmailService} from '../services/EmailService.js'; import {EventService} from '../services/EventService.js'; import {NotFound} from '../exceptions/index.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; /** * Public API Actions Controller @@ -47,7 +48,8 @@ export class Actions { */ @Post('track') @Middleware([requirePublicKey]) - public async track(req: Request, res: Response) { + @CatchAsync + public async track(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; // Zod validation - errors automatically handled by global error handler @@ -152,7 +154,8 @@ export class Actions { */ @Post('send') @Middleware([requireSecretKey]) - public async send(req: Request, res: Response) { + @CatchAsync + public async send(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; // Zod validation - errors automatically handled by global error handler diff --git a/apps/api/src/controllers/Activity.ts b/apps/api/src/controllers/Activity.ts index 1810eb9..2c0b61b 100644 --- a/apps/api/src/controllers/Activity.ts +++ b/apps/api/src/controllers/Activity.ts @@ -1,9 +1,10 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {ActivityService, ActivityType} from '../services/ActivityService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('activity') export class Activity { @@ -21,7 +22,8 @@ export class Activity { */ @Get('') @Middleware([requireAuth]) - public async getActivities(req: Request, res: Response) { + @CatchAsync + public async getActivities(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const cursor = req.query.cursor as string | undefined; @@ -61,7 +63,8 @@ export class Activity { */ @Get('stats') @Middleware([requireAuth]) - public async getStats(req: Request, res: Response) { + @CatchAsync + public async getStats(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; @@ -80,7 +83,8 @@ export class Activity { */ @Get('recent-count') @Middleware([requireAuth]) - public async getRecentCount(req: Request, res: Response) { + @CatchAsync + public async getRecentCount(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes @@ -95,7 +99,8 @@ export class Activity { */ @Get('types') @Middleware([requireAuth]) - public async getTypes(_req: Request, res: Response) { + @CatchAsync + public async getTypes(_req: Request, res: Response, next: NextFunction) { const types = Object.values(ActivityType); return res.status(200).json({types}); } @@ -110,7 +115,8 @@ export class Activity { */ @Get('upcoming') @Middleware([requireAuth]) - public async getUpcoming(req: Request, res: Response) { + @CatchAsync + public async getUpcoming(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90); diff --git a/apps/api/src/controllers/Analytics.ts b/apps/api/src/controllers/Analytics.ts index bd4b91e..bd932db 100644 --- a/apps/api/src/controllers/Analytics.ts +++ b/apps/api/src/controllers/Analytics.ts @@ -1,9 +1,10 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {AnalyticsService} from '../services/AnalyticsService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('analytics') export class Analytics { @@ -19,7 +20,8 @@ export class Analytics { */ @Get('timeseries') @Middleware([requireAuth]) - public async getTimeSeries(req: Request, res: Response) { + @CatchAsync + public async getTimeSeries(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; @@ -40,7 +42,8 @@ export class Analytics { */ @Get('top-campaigns') @Middleware([requireAuth]) - public async getTopCampaigns(req: Request, res: Response) { + @CatchAsync + public async getTopCampaigns(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index 3eb77f0..b49c32b 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -1,6 +1,6 @@ import {Controller, Get, Post} from '@overnightjs/core'; import {AuthenticationSchemas} from '@plunk/shared'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; @@ -9,11 +9,13 @@ import {jwt} from '../middleware/auth.js'; import {AuthService} from '../services/AuthService.js'; import {UserService} from '../services/UserService.js'; import {Keys} from '../services/keys.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('auth') export class Auth { @Post('login') - public async login(req: Request, res: Response) { + @CatchAsync + public async login(req: Request, res: Response, next: NextFunction) { const {email, password} = AuthenticationSchemas.login.parse(req.body); const user = await UserService.email(email); @@ -43,7 +45,8 @@ export class Auth { } @Post('signup') - public async signup(req: Request, res: Response) { + @CatchAsync + public async signup(req: Request, res: Response, next: NextFunction) { const {email, password} = AuthenticationSchemas.login.parse(req.body); const user = await UserService.email(email); diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index b57df9c..e487231 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -1,7 +1,7 @@ import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignSchemas} from '@plunk/shared'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import {HttpException} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; @@ -9,6 +9,7 @@ import {requireAuth} from '../middleware/auth.js'; import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; import {type SegmentFilter} from '../services/SegmentService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('campaigns') export class Campaigns { @@ -18,7 +19,8 @@ export class Campaigns { */ @Post('') @Middleware([requireAuth]) - private async create(req: Request, res: Response) { + @CatchAsync + private async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, segmentId} = CampaignSchemas.create.parse(req.body); @@ -60,7 +62,8 @@ export class Campaigns { */ @Get('') @Middleware([requireAuth]) - private async list(req: Request, res: Response) { + @CatchAsync + private async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const status = req.query.status as CampaignStatus | undefined; const page = parseInt(req.query.page as string) || 1; @@ -92,7 +95,8 @@ export class Campaigns { */ @Get(':id') @Middleware([requireAuth]) - private async get(req: Request, res: Response) { + @CatchAsync + private async get(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -110,7 +114,8 @@ export class Campaigns { */ @Put(':id') @Middleware([requireAuth]) - private async update(req: Request, res: Response) { + @CatchAsync + private async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, segmentId} = @@ -155,7 +160,8 @@ export class Campaigns { */ @Delete(':id') @Middleware([requireAuth]) - private async delete(req: Request, res: Response) { + @CatchAsync + private async delete(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -173,7 +179,8 @@ export class Campaigns { */ @Post(':id/duplicate') @Middleware([requireAuth]) - private async duplicate(req: Request, res: Response) { + @CatchAsync + private async duplicate(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -192,7 +199,8 @@ export class Campaigns { */ @Post(':id/send') @Middleware([requireAuth]) - private async send(req: Request, res: Response) { + @CatchAsync + private async send(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; const scheduledFor = req.body?.scheduledFor; @@ -222,7 +230,8 @@ export class Campaigns { */ @Post(':id/cancel') @Middleware([requireAuth]) - private async cancel(req: Request, res: Response) { + @CatchAsync + private async cancel(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -241,7 +250,8 @@ export class Campaigns { */ @Get(':id/stats') @Middleware([requireAuth]) - private async stats(req: Request, res: Response) { + @CatchAsync + private async stats(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -259,7 +269,8 @@ export class Campaigns { */ @Post(':id/test') @Middleware([requireAuth]) - private async sendTest(req: Request, res: Response) { + @CatchAsync + private async sendTest(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; const {email} = CampaignSchemas.sendTest.parse(req.body); diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 7450085..714aeec 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -1,11 +1,12 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import multer from 'multer'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {ContactService} from '../services/ContactService.js'; import {QueueService} from '../services/QueueService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; // Configure multer for file uploads (memory storage) const upload = multer({ @@ -31,7 +32,8 @@ export class Contacts { */ @Get('') @Middleware([requireAuth]) - public async list(req: Request, res: Response) { + @CatchAsync + public async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const cursor = req.query.cursor as string | undefined; @@ -48,7 +50,8 @@ export class Contacts { */ @Get('fields') @Middleware([requireAuth]) - public async getAvailableFields(req: Request, res: Response) { + @CatchAsync + public async getAvailableFields(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; try { @@ -73,7 +76,8 @@ export class Contacts { */ @Get('fields/:field/values') @Middleware([requireAuth]) - public async getFieldValues(req: Request, res: Response) { + @CatchAsync + public async getFieldValues(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const field = req.params.field; const limit = Math.min(parseInt(req.query.limit as string) || 100, 200); @@ -105,7 +109,8 @@ export class Contacts { */ @Get(':id') @Middleware([requireAuth]) - public async get(req: Request, res: Response) { + @CatchAsync + public async get(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const contactId = req.params.id; @@ -124,7 +129,8 @@ export class Contacts { */ @Post('') @Middleware([requireAuth]) - public async create(req: Request, res: Response) { + @CatchAsync + public async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {email, data, subscribed} = req.body; @@ -153,7 +159,8 @@ export class Contacts { */ @Patch(':id') @Middleware([requireAuth]) - public async update(req: Request, res: Response) { + @CatchAsync + public async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const contactId = req.params.id; const {email, data, subscribed} = req.body; @@ -173,7 +180,8 @@ export class Contacts { */ @Delete(':id') @Middleware([requireAuth]) - public async delete(req: Request, res: Response) { + @CatchAsync + public async delete(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const contactId = req.params.id; @@ -191,7 +199,8 @@ export class Contacts { * PUBLIC: Get contact information (no auth required) */ @Get('public/:id') - public async getPublic(req: Request, res: Response) { + @CatchAsync + public async getPublic(req: Request, res: Response, next: NextFunction) { const contactId = req.params.id; if (!contactId) { @@ -212,7 +221,8 @@ export class Contacts { * PUBLIC: Subscribe a contact (no auth required) */ @Post('public/:id/subscribe') - public async subscribePublic(req: Request, res: Response) { + @CatchAsync + public async subscribePublic(req: Request, res: Response, next: NextFunction) { const contactId = req.params.id; if (!contactId) { @@ -233,7 +243,8 @@ export class Contacts { * PUBLIC: Unsubscribe a contact (no auth required) */ @Post('public/:id/unsubscribe') - public async unsubscribePublic(req: Request, res: Response) { + @CatchAsync + public async unsubscribePublic(req: Request, res: Response, next: NextFunction) { const contactId = req.params.id; if (!contactId) { @@ -255,7 +266,8 @@ export class Contacts { */ @Post('import') @Middleware([requireAuth, upload.single('file')]) - public async importCsv(req: Request, res: Response) { + @CatchAsync + public async importCsv(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; if (!req.file) { @@ -288,7 +300,8 @@ export class Contacts { */ @Get('import/:jobId') @Middleware([requireAuth]) - public async getImportStatus(req: Request, res: Response) { + @CatchAsync + public async getImportStatus(req: Request, res: Response, next: NextFunction) { const jobId = req.params.jobId; if (!jobId) { diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index fd2bfd0..160881c 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -1,6 +1,6 @@ import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core'; import {DomainSchemas, UtilitySchemas} from '@plunk/shared'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import {redis} from '../database/redis.js'; import {NotFound} from '../exceptions/index.js'; @@ -9,6 +9,7 @@ import {isAuthenticated} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {Keys} from '../services/keys.js'; import {prisma} from '../database/prisma.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('domains') export class Domains { @@ -17,7 +18,8 @@ export class Domains { */ @Get('project/:projectId') @Middleware([isAuthenticated]) - public async getProjectDomains(req: Request, res: Response) { + @CatchAsync + public async getProjectDomains(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {projectId} = DomainSchemas.projectId.parse(req.params); @@ -43,7 +45,8 @@ export class Domains { */ @Post('') @Middleware([isAuthenticated]) - public async addDomain(req: Request, res: Response) { + @CatchAsync + public async addDomain(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {projectId, domain} = DomainSchemas.create.parse(req.body); @@ -102,7 +105,8 @@ export class Domains { */ @Get(':id/verify') @Middleware([isAuthenticated]) - public async checkVerification(req: Request, res: Response) { + @CatchAsync + public async checkVerification(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = UtilitySchemas.id.parse(req.params); @@ -138,7 +142,8 @@ export class Domains { */ @Delete(':id') @Middleware([isAuthenticated]) - public async removeDomain(req: Request, res: Response) { + @CatchAsync + public async removeDomain(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = UtilitySchemas.id.parse(req.params); diff --git a/apps/api/src/controllers/Events.ts b/apps/api/src/controllers/Events.ts index 137a45f..c3c1fab 100644 --- a/apps/api/src/controllers/Events.ts +++ b/apps/api/src/controllers/Events.ts @@ -1,9 +1,10 @@ import {Controller, Get, Middleware, Post} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {EventService} from '../services/EventService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('events') export class Events { @@ -13,7 +14,8 @@ export class Events { */ @Post('track') @Middleware([requireAuth]) - public async track(req: Request, res: Response) { + @CatchAsync + public async track(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {name, contactId, emailId, data} = req.body; @@ -32,7 +34,8 @@ export class Events { */ @Get('') @Middleware([requireAuth]) - public async list(req: Request, res: Response) { + @CatchAsync + public async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const eventName = req.query.eventName as string | undefined; const limit = parseInt(req.query.limit as string) || 100; @@ -48,7 +51,8 @@ export class Events { */ @Get('stats') @Middleware([requireAuth]) - public async stats(req: Request, res: Response) { + @CatchAsync + public async stats(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; @@ -64,7 +68,8 @@ export class Events { */ @Get('contact/:contactId') @Middleware([requireAuth]) - public async getContactEvents(req: Request, res: Response) { + @CatchAsync + public async getContactEvents(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const contactId = req.params.contactId; const limit = parseInt(req.query.limit as string) || 50; @@ -84,7 +89,8 @@ export class Events { */ @Get('names') @Middleware([requireAuth]) - public async getEventNames(req: Request, res: Response) { + @CatchAsync + public async getEventNames(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const eventNames = await EventService.getUniqueEventNames(auth.projectId!); diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts index 0bc7637..7867677 100644 --- a/apps/api/src/controllers/Oauth/Github.ts +++ b/apps/api/src/controllers/Oauth/Github.ts @@ -1,5 +1,5 @@ import {Controller, Get} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import { API_URI, @@ -11,6 +11,7 @@ import { import {prisma} from '../../database/prisma.js'; import {jwt} from '../../middleware/auth.js'; import {UserService} from '../../services/UserService.js'; +import {CatchAsync} from '../../utils/asyncHandler.js'; @Controller('github') export class Github { @@ -31,7 +32,8 @@ export class Github { } @Get('callback') - public async callback(req: Request, res: Response) { + @CatchAsync + public async callback(req: Request, res: Response, next: NextFunction) { if (!GITHUB_OAUTH_ENABLED) { return res.status(404).json({error: 'GitHub OAuth is not configured'}); } diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts index 97b6977..c57fe3b 100644 --- a/apps/api/src/controllers/Oauth/Google.ts +++ b/apps/api/src/controllers/Oauth/Google.ts @@ -1,5 +1,5 @@ import {Controller, Get} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import { API_URI, @@ -11,6 +11,7 @@ import { import {prisma} from '../../database/prisma.js'; import {jwt} from '../../middleware/auth.js'; import {UserService} from '../../services/UserService.js'; +import {CatchAsync} from '../../utils/asyncHandler.js'; @Controller('google') export class Google { @@ -26,7 +27,8 @@ export class Google { } @Get('callback') - public async callback(req: Request, res: Response) { + @CatchAsync + public async callback(req: Request, res: Response, next: NextFunction) { if (!GOOGLE_OAUTH_ENABLED) { return res.status(404).json({error: 'Google OAuth is not configured'}); } diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index 1104855..9ec7ca7 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -1,10 +1,11 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('projects') export class Projects { @@ -14,7 +15,8 @@ export class Projects { */ @Get(':id/setup-state') @Middleware([requireAuth]) - private async getSetupState(req: Request, res: Response) { + @CatchAsync + private async getSetupState(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -86,7 +88,8 @@ export class Projects { */ @Get(':id/members') @Middleware([requireAuth]) - private async getMembers(req: Request, res: Response) { + @CatchAsync + private async getMembers(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index 062cd59..95c2508 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -1,9 +1,10 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {SegmentService} from '../services/SegmentService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('segments') export class Segments { @@ -13,7 +14,8 @@ export class Segments { */ @Get('') @Middleware([requireAuth]) - public async list(req: Request, res: Response) { + @CatchAsync + public async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segments = await SegmentService.list(auth.projectId!); @@ -27,7 +29,8 @@ export class Segments { */ @Get(':id') @Middleware([requireAuth]) - public async get(req: Request, res: Response) { + @CatchAsync + public async get(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; @@ -46,7 +49,8 @@ export class Segments { */ @Get(':id/contacts') @Middleware([requireAuth]) - public async getContacts(req: Request, res: Response) { + @CatchAsync + public async getContacts(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; const page = parseInt(req.query.page as string) || 1; @@ -67,7 +71,8 @@ export class Segments { */ @Post('') @Middleware([requireAuth]) - public async create(req: Request, res: Response) { + @CatchAsync + public async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {name, description, filters, trackMembership} = req.body; @@ -95,7 +100,8 @@ export class Segments { */ @Patch(':id') @Middleware([requireAuth]) - public async update(req: Request, res: Response) { + @CatchAsync + public async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; const {name, description, filters, trackMembership} = req.body; @@ -124,7 +130,8 @@ export class Segments { */ @Delete(':id') @Middleware([requireAuth]) - public async delete(req: Request, res: Response) { + @CatchAsync + public async delete(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; @@ -143,7 +150,8 @@ export class Segments { */ @Post(':id/compute') @Middleware([requireAuth]) - public async compute(req: Request, res: Response) { + @CatchAsync + public async compute(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; @@ -162,7 +170,8 @@ export class Segments { */ @Post(':id/refresh') @Middleware([requireAuth]) - public async refresh(req: Request, res: Response) { + @CatchAsync + public async refresh(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const segmentId = req.params.id; diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 9611296..42b9c90 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -1,11 +1,12 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {TemplateType} from '@plunk/db'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {TemplateService} from '../services/TemplateService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('templates') export class Templates { @@ -15,7 +16,8 @@ export class Templates { */ @Get('') @Middleware([requireAuth]) - public async list(req: Request, res: Response) { + @CatchAsync + public async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); @@ -33,7 +35,8 @@ export class Templates { */ @Get(':id') @Middleware([requireAuth]) - public async get(req: Request, res: Response) { + @CatchAsync + public async get(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const templateId = req.params.id; @@ -52,7 +55,8 @@ export class Templates { */ @Post('') @Middleware([requireAuth]) - public async create(req: Request, res: Response) { + @CatchAsync + public async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {name, description, subject, body, from, fromName, replyTo, type} = req.body; @@ -95,7 +99,8 @@ export class Templates { */ @Patch(':id') @Middleware([requireAuth]) - public async update(req: Request, res: Response) { + @CatchAsync + public async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const templateId = req.params.id; const {name, description, subject, body, from, fromName, replyTo, type} = req.body; @@ -129,7 +134,8 @@ export class Templates { */ @Delete(':id') @Middleware([requireAuth]) - public async delete(req: Request, res: Response) { + @CatchAsync + public async delete(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const templateId = req.params.id; @@ -148,7 +154,8 @@ export class Templates { */ @Post(':id/duplicate') @Middleware([requireAuth]) - public async duplicate(req: Request, res: Response) { + @CatchAsync + public async duplicate(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const templateId = req.params.id; @@ -167,7 +174,8 @@ export class Templates { */ @Get(':id/usage') @Middleware([requireAuth]) - public async getUsage(req: Request, res: Response) { + @CatchAsync + public async getUsage(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const templateId = req.params.id; diff --git a/apps/api/src/controllers/Uploads.ts b/apps/api/src/controllers/Uploads.ts index d776f4b..e01ec83 100644 --- a/apps/api/src/controllers/Uploads.ts +++ b/apps/api/src/controllers/Uploads.ts @@ -1,10 +1,11 @@ import {Controller, Middleware, Post} from '@overnightjs/core'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import multer from 'multer'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import * as S3Service from '../services/S3Service.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; // Configure multer for file uploads (memory storage) const upload = multer({ @@ -32,7 +33,8 @@ export class Uploads { */ @Post('image') @Middleware([requireAuth, upload.single('image')]) - public async uploadImage(req: Request, res: Response) { + @CatchAsync + public async uploadImage(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; try { diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 067a31e..9ef21a9 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -2,7 +2,7 @@ import {randomBytes} from 'node:crypto'; import {Controller, Get, Middleware, Patch, Post, Put} from '@overnightjs/core'; import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import { DASHBOARD_URI, @@ -23,12 +23,14 @@ import {isAuthenticated} from '../middleware/auth.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; import {SecurityService} from '../services/SecurityService.js'; import {UserService} from '../services/UserService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('users') export class Users { @Get('@me') @Middleware([isAuthenticated]) - public async me(req: Request, res: Response) { + @CatchAsync + public async me(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; if (!auth.userId) { @@ -46,7 +48,8 @@ export class Users { @Get('@me/projects') @Middleware([isAuthenticated]) - public async meProjects(req: Request, res: Response) { + @CatchAsync + public async meProjects(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; if (!auth.userId) { @@ -60,7 +63,8 @@ export class Users { @Post('@me/projects') @Middleware([isAuthenticated]) - public async createProject(req: Request, res: Response) { + @CatchAsync + public async createProject(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; if (!auth.userId) { @@ -93,7 +97,8 @@ export class Users { @Patch('@me/projects/:id') @Middleware([isAuthenticated]) - public async updateProject(req: Request, res: Response) { + @CatchAsync + public async updateProject(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; const data = ProjectSchemas.update.parse(req.body); @@ -124,7 +129,8 @@ export class Users { @Post('@me/projects/:id/regenerate-keys') @Middleware([isAuthenticated]) - public async regenerateProjectKeys(req: Request, res: Response) { + @CatchAsync + public async regenerateProjectKeys(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -161,7 +167,8 @@ export class Users { @Post('@me/projects/:id/checkout') @Middleware([isAuthenticated]) - public async createCheckoutSession(req: Request, res: Response) { + @CatchAsync + public async createCheckoutSession(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -239,7 +246,8 @@ export class Users { @Post('@me/projects/:id/billing-portal') @Middleware([isAuthenticated]) - public async createBillingPortalSession(req: Request, res: Response) { + @CatchAsync + public async createBillingPortalSession(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -288,7 +296,8 @@ export class Users { @Get('@me/projects/:id/billing-limits') @Middleware([isAuthenticated]) - public async getBillingLimits(req: Request, res: Response) { + @CatchAsync + public async getBillingLimits(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -320,7 +329,8 @@ export class Users { @Put('@me/projects/:id/billing-limits') @Middleware([isAuthenticated]) - public async updateBillingLimits(req: Request, res: Response) { + @CatchAsync + public async updateBillingLimits(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -386,7 +396,8 @@ export class Users { @Get('@me/projects/:id/billing-consumption') @Middleware([isAuthenticated]) - public async getBillingConsumption(req: Request, res: Response) { + @CatchAsync + public async getBillingConsumption(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -485,7 +496,8 @@ export class Users { @Get('@me/projects/:id/billing-invoices') @Middleware([isAuthenticated]) - public async getBillingInvoices(req: Request, res: Response) { + @CatchAsync + public async getBillingInvoices(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; @@ -572,7 +584,8 @@ export class Users { @Get('@me/projects/:id/security') @Middleware([isAuthenticated]) - public async getSecurityHealth(req: Request, res: Response) { + @CatchAsync + public async getSecurityHealth(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {id} = req.params; diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index d7cb5d0..2aa359b 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -1,7 +1,7 @@ import {Controller, Post} from '@overnightjs/core'; import type {Prisma} from '@plunk/db'; import {EmailStatus} from '@plunk/db'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; import type Stripe from 'stripe'; @@ -9,6 +9,7 @@ import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {SecurityService} from '../services/SecurityService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; /** * Webhooks Controller @@ -21,7 +22,8 @@ export class Webhooks { * Handles email events: delivery, open, click, bounce, complaint */ @Post('sns') - public async receiveSNSWebhook(req: Request, res: Response) { + @CatchAsync + public async receiveSNSWebhook(req: Request, res: Response, next: NextFunction) { try { // Parse the SNS message body const body = JSON.parse(req.body.Message); @@ -147,7 +149,8 @@ export class Webhooks { * Handles subscription and payment events: checkout.session.completed, invoice.paid, etc. */ @Post('incoming/stripe') - public async receiveStripeWebhook(req: Request, res: Response) { + @CatchAsync + public async receiveStripeWebhook(req: Request, res: Response, next: NextFunction) { // Return 404 if billing is disabled if (!STRIPE_ENABLED || !stripe) { signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled'); diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index a8f240f..810f2ea 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -1,10 +1,11 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {WorkflowExecutionStatus} from '@plunk/db'; -import type {Request, Response} from 'express'; +import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth} from '../middleware/auth.js'; import {WorkflowService} from '../services/WorkflowService.js'; +import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('workflows') export class Workflows { @@ -14,7 +15,8 @@ export class Workflows { */ @Get('') @Middleware([requireAuth]) - public async list(req: Request, res: Response) { + @CatchAsync + public async list(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); @@ -31,7 +33,8 @@ export class Workflows { */ @Get(':id') @Middleware([requireAuth]) - public async get(req: Request, res: Response) { + @CatchAsync + public async get(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; @@ -50,7 +53,8 @@ export class Workflows { */ @Post('') @Middleware([requireAuth]) - public async create(req: Request, res: Response) { + @CatchAsync + public async create(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const {name, description, eventName, enabled, allowReentry} = req.body; @@ -79,7 +83,8 @@ export class Workflows { */ @Patch(':id') @Middleware([requireAuth]) - public async update(req: Request, res: Response) { + @CatchAsync + public async update(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body; @@ -106,7 +111,8 @@ export class Workflows { */ @Delete(':id') @Middleware([requireAuth]) - public async delete(req: Request, res: Response) { + @CatchAsync + public async delete(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; @@ -125,7 +131,8 @@ export class Workflows { */ @Post(':id/steps') @Middleware([requireAuth]) - public async addStep(req: Request, res: Response) { + @CatchAsync + public async addStep(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const {type, name, position, config, templateId, autoConnect} = req.body; @@ -156,7 +163,8 @@ export class Workflows { */ @Patch(':id/steps/:stepId') @Middleware([requireAuth]) - public async updateStep(req: Request, res: Response) { + @CatchAsync + public async updateStep(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const stepId = req.params.stepId; @@ -182,7 +190,8 @@ export class Workflows { */ @Delete(':id/steps/:stepId') @Middleware([requireAuth]) - public async deleteStep(req: Request, res: Response) { + @CatchAsync + public async deleteStep(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const stepId = req.params.stepId; @@ -202,7 +211,8 @@ export class Workflows { */ @Post(':id/transitions') @Middleware([requireAuth]) - public async createTransition(req: Request, res: Response) { + @CatchAsync + public async createTransition(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const {fromStepId, toStepId, condition, priority} = req.body; @@ -231,7 +241,8 @@ export class Workflows { */ @Delete(':id/transitions/:transitionId') @Middleware([requireAuth]) - public async deleteTransition(req: Request, res: Response) { + @CatchAsync + public async deleteTransition(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const transitionId = req.params.transitionId; @@ -251,7 +262,8 @@ export class Workflows { */ @Post(':id/executions') @Middleware([requireAuth]) - public async startExecution(req: Request, res: Response) { + @CatchAsync + public async startExecution(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const {contactId, context} = req.body; @@ -275,7 +287,8 @@ export class Workflows { */ @Get(':id/executions') @Middleware([requireAuth]) - public async listExecutions(req: Request, res: Response) { + @CatchAsync + public async listExecutions(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const page = parseInt(req.query.page as string) || 1; @@ -297,7 +310,8 @@ export class Workflows { */ @Get(':id/executions/:executionId') @Middleware([requireAuth]) - public async getExecution(req: Request, res: Response) { + @CatchAsync + public async getExecution(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const executionId = req.params.executionId; @@ -317,7 +331,8 @@ export class Workflows { */ @Delete(':id/executions/:executionId') @Middleware([requireAuth]) - public async cancelExecution(req: Request, res: Response) { + @CatchAsync + public async cancelExecution(req: Request, res: Response, next: NextFunction) { const auth = res.locals.auth as AuthResponse; const workflowId = req.params.id; const executionId = req.params.executionId; diff --git a/apps/api/src/utils/asyncHandler.ts b/apps/api/src/utils/asyncHandler.ts new file mode 100644 index 0000000..b5b7b7f --- /dev/null +++ b/apps/api/src/utils/asyncHandler.ts @@ -0,0 +1,24 @@ +import type {NextFunction, Request, Response} from 'express'; + +/** + * Method decorator to catch async errors in OvernightJS controllers + * Wraps async controller methods to ensure errors are passed to Express error handler + * + * Usage: + * @Post('route') + * @CatchAsync + * public async myMethod(req: Request, res: Response) { ... } + * + * This decorator ensures all errors (sync and async) thrown in the method + * are caught and passed to the Express error middleware, fixing the hanging + * request issue when Zod validation fails. + */ +export function CatchAsync(target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function (req: Request, res: Response, next: NextFunction) { + Promise.resolve(originalMethod.call(this, req, res, next)).catch(next); + }; + + return descriptor; +} diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 1970904..7996d35 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1970904..7996d35 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.