diff --git a/apps/api/src/__tests__/integration/actions.test.ts b/apps/api/src/__tests__/integration/actions.test.ts index 4e75dc1..0826f50 100644 --- a/apps/api/src/__tests__/integration/actions.test.ts +++ b/apps/api/src/__tests__/integration/actions.test.ts @@ -10,7 +10,7 @@ import { NotAuthenticated, NotFound, RateLimitError, - ValidationError + ValidationError, } from '../../exceptions/index.js'; import {EmailService} from '../../services/EmailService.js'; diff --git a/apps/api/src/__tests__/integration/campaigns.test.ts b/apps/api/src/__tests__/integration/campaigns.test.ts index f083714..fa159a0 100644 --- a/apps/api/src/__tests__/integration/campaigns.test.ts +++ b/apps/api/src/__tests__/integration/campaigns.test.ts @@ -1,5 +1,5 @@ -import {describe, it, expect, beforeEach, beforeAll} from 'vitest'; -import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; +import {beforeAll, beforeEach, describe, expect, it} from 'vitest'; +import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {factories, getPrismaClient} from '../../../../../test/helpers'; // Note: To run these integration tests, you need to: diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 73eda76..86b2724 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -20,7 +20,7 @@ import { SMTP_ENABLED, STRIPE_ENABLED, TRACKING_TOGGLE_ENABLED, - WIKI_URI + WIKI_URI, } from './app/constants.js'; import {Actions} from './controllers/Actions.js'; import {Activity} from './controllers/Activity.js'; diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 199f5ee..a124eef 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -1,8 +1,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core'; import {ActionSchemas} from '@plunk/shared'; import type {NextFunction, Request, Response} from 'express'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requirePublicKey, requireSecretKey} from '../middleware/auth.js'; import {prisma} from '../database/prisma.js'; import {ContactService} from '../services/ContactService.js'; @@ -52,7 +50,7 @@ export class Actions { @Middleware([requirePublicKey]) @CatchAsync public async track(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; // Zod validation - errors automatically handled by global error handler const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body); @@ -173,7 +171,7 @@ export class Actions { @Middleware([requireSecretKey]) @CatchAsync public async send(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; // Zod validation - errors automatically handled by global error handler const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} = diff --git a/apps/api/src/controllers/Activity.ts b/apps/api/src/controllers/Activity.ts index 570eef0..c29676e 100644 --- a/apps/api/src/controllers/Activity.ts +++ b/apps/api/src/controllers/Activity.ts @@ -1,8 +1,6 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import {ActivityType} from '@plunk/types'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {ActivityService} from '../services/ActivityService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -25,7 +23,7 @@ export class Activity { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getActivities(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const cursor = req.query.cursor as string | undefined; const contactId = req.query.contactId as string | undefined; @@ -66,7 +64,7 @@ export class Activity { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getStats(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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; @@ -86,7 +84,7 @@ export class Activity { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getRecentCount(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes); @@ -118,7 +116,7 @@ export class Activity { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getUpcoming(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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 1fac79d..76a160c 100644 --- a/apps/api/src/controllers/Analytics.ts +++ b/apps/api/src/controllers/Analytics.ts @@ -1,7 +1,5 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {AnalyticsService} from '../services/AnalyticsService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -22,7 +20,7 @@ export class Analytics { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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; @@ -44,7 +42,7 @@ export class Analytics { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; @@ -68,7 +66,7 @@ export class Analytics { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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; @@ -92,7 +90,7 @@ export class Analytics { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTopEvents(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const limit = Math.min(parseInt(req.query.limit as string) || 5, 20); 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; diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index fa04626..e9d836e 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -4,7 +4,6 @@ import {CampaignSchemas, UtilitySchemas} from '@plunk/shared'; import type {NextFunction, Request, Response} from 'express'; import {HttpException} from '../exceptions/index.js'; -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; @@ -20,7 +19,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async create(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = CampaignSchemas.create.parse(req.body); @@ -63,7 +62,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const status = req.query.status as CampaignStatus | undefined; const page = parseInt(req.query.page as string) || 1; const pageSize = parseInt(req.query.pageSize as string) || 20; @@ -96,7 +95,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async get(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const campaign = await CampaignService.get(auth.projectId, id!); @@ -115,7 +114,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async update(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = req.body; @@ -161,7 +160,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async delete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); await CampaignService.delete(auth.projectId, id!); @@ -180,7 +179,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async duplicate(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const campaign = await CampaignService.duplicate(auth.projectId, id!); @@ -200,7 +199,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async send(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const scheduledFor = req.body?.scheduledFor; @@ -231,7 +230,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async cancel(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const campaign = await CampaignService.cancel(auth.projectId, id!); @@ -251,7 +250,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async stats(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const stats = await CampaignService.getStats(auth.projectId, id!); @@ -270,7 +269,7 @@ export class Campaigns { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async sendTest(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(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 2718189..ba89952 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor import type {NextFunction, Request, Response} from 'express'; import multer from 'multer'; import signale from 'signale'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {ContactService} from '../services/ContactService.js'; import {QueueService} from '../services/QueueService.js'; @@ -35,7 +33,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const cursor = req.query.cursor as string | undefined; const search = req.query.search as string | undefined; @@ -54,7 +52,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; try { const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!); @@ -80,7 +78,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getFieldValues(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const field = req.params.field; const limit = Math.min(parseInt(req.query.limit as string) || 100, 200); @@ -113,7 +111,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const contactId = req.params.id; if (!contactId) { @@ -133,7 +131,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {email, data, subscribed} = req.body; if (!email) { @@ -163,7 +161,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const contactId = req.params.id; const {email, data, subscribed} = req.body; @@ -184,7 +182,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const contactId = req.params.id; if (!contactId) { @@ -284,7 +282,7 @@ export class Contacts { @Middleware([requireAuth, upload.single('file')]) @CatchAsync public async importCsv(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; if (!req.file) { return res.status(400).json({error: 'CSV file is required'}); @@ -349,7 +347,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getFieldUsage(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const field = req.params.field; if (!field) { @@ -376,7 +374,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteField(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const field = req.params.field; if (!field) { @@ -402,7 +400,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {contactIds} = req.body; if (!Array.isArray(contactIds) || contactIds.length === 0) { @@ -437,7 +435,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {contactIds} = req.body; if (!Array.isArray(contactIds) || contactIds.length === 0) { @@ -471,7 +469,7 @@ export class Contacts { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkDelete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {contactIds} = req.body; if (!Array.isArray(contactIds) || contactIds.length === 0) { diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index c868992..44a19e5 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -4,12 +4,10 @@ import type {NextFunction, Request, Response} from 'express'; import {redis} from '../database/redis.js'; import {NotFound} from '../exceptions/index.js'; -import type {AuthResponse} from '../middleware/auth.js'; import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {Keys} from '../services/keys.js'; import {MembershipService} from '../services/MembershipService.js'; -import {prisma} from '../database/prisma.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @Controller('domains') @@ -21,7 +19,7 @@ export class Domains { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {projectId} = DomainSchemas.projectId.parse(req.params); // Verify user has access to this project @@ -39,7 +37,7 @@ export class Domains { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async addDomain(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {projectId, domain} = DomainSchemas.create.parse(req.body); if (!auth.userId) { @@ -87,7 +85,7 @@ export class Domains { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async checkVerification(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const domain = await DomainService.id(id); @@ -115,7 +113,7 @@ export class Domains { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async removeDomain(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const domain = await DomainService.id(id); diff --git a/apps/api/src/controllers/Events.ts b/apps/api/src/controllers/Events.ts index d365269..d37ef11 100644 --- a/apps/api/src/controllers/Events.ts +++ b/apps/api/src/controllers/Events.ts @@ -1,8 +1,6 @@ import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {EventService} from '../services/EventService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -17,7 +15,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async track(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {name, contactId, emailId, data} = req.body; if (!name) { @@ -37,7 +35,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const eventName = req.query.eventName as string | undefined; const limit = parseInt(req.query.limit as string) || 100; @@ -54,7 +52,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async stats(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; 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; @@ -71,7 +69,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getContactEvents(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const contactId = req.params.contactId; const limit = parseInt(req.query.limit as string) || 50; @@ -92,7 +90,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getEventNames(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const eventNames = await EventService.getUniqueEventNames(auth.projectId!); @@ -108,7 +106,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getEventUsage(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const eventName = req.params.eventName; if (!eventName) { @@ -135,7 +133,7 @@ export class Events { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteEvent(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const eventName = req.params.eventName; if (!eventName) { diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index 023e39e..7958500 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -4,7 +4,6 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {MembershipService} from '../services/MembershipService.js'; import {SecurityService} from '../services/SecurityService.js'; @@ -20,7 +19,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getSetupState(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Verify user has access to this project @@ -84,7 +83,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Verify user has access to this project @@ -107,7 +106,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getMembers(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Verify user has access to this project @@ -131,7 +130,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async addMember(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Validate params @@ -182,7 +181,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id, userId} = req.params; // Validate params @@ -235,7 +234,7 @@ export class Projects { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async removeMember(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id, userId} = req.params; // Validate params diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index 86ad72e..7556add 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -1,7 +1,5 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {SegmentService} from '../services/SegmentService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -16,7 +14,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segments = await SegmentService.list(auth.projectId!); @@ -31,7 +29,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; if (!segmentId) { @@ -51,7 +49,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getContacts(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); @@ -73,7 +71,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {name, description, condition, trackMembership} = req.body; if (!name) { @@ -102,7 +100,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; const {name, description, condition, trackMembership} = req.body; @@ -132,7 +130,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; if (!segmentId) { @@ -152,7 +150,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async compute(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; if (!segmentId) { @@ -172,7 +170,7 @@ export class Segments { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async refresh(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const segmentId = req.params.id; if (!segmentId) { diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index c29aea1..26a9cf3 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -1,8 +1,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {TemplateType} from '@plunk/db'; import type {NextFunction, Request, Response} from 'express'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {TemplateService} from '../services/TemplateService.js'; @@ -18,7 +16,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const search = req.query.search as string | undefined; @@ -37,7 +35,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const templateId = req.params.id; if (!templateId) { @@ -57,7 +55,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {name, description, subject, body, from, fromName, replyTo, type} = req.body; if (!name) { @@ -101,7 +99,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const templateId = req.params.id; const {name, description, subject, body, from, fromName, replyTo, type} = req.body; @@ -136,7 +134,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const templateId = req.params.id; if (!templateId) { @@ -156,7 +154,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async duplicate(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const templateId = req.params.id; if (!templateId) { @@ -176,7 +174,7 @@ export class Templates { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getUsage(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const templateId = req.params.id; if (!templateId) { diff --git a/apps/api/src/controllers/Uploads.ts b/apps/api/src/controllers/Uploads.ts index f675315..650123a 100644 --- a/apps/api/src/controllers/Uploads.ts +++ b/apps/api/src/controllers/Uploads.ts @@ -2,8 +2,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import multer from 'multer'; import signale from 'signale'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import * as S3Service from '../services/S3Service.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -36,7 +34,7 @@ export class Uploads { @Middleware([requireAuth, requireEmailVerified, upload.single('image')]) @CatchAsync public async uploadImage(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; try { if (!S3Service.isS3Enabled()) { diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 06b8e71..c688d86 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -8,7 +8,6 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js'; -import type {AuthResponse} from '../middleware/auth.js'; import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; import {MembershipService} from '../services/MembershipService.js'; @@ -24,7 +23,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async me(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; if (!auth.userId) { throw new NotAuthenticated(); @@ -43,7 +42,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async meProjects(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; if (!auth.userId) { throw new NotAuthenticated(); @@ -58,7 +57,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createProject(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; if (!auth.userId) { throw new NotAuthenticated(); @@ -105,7 +104,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async updateProject(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const data = ProjectSchemas.update.parse(req.body); @@ -125,7 +124,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Verify user has admin/owner access to this project @@ -165,7 +164,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const {currency} = req.query; @@ -245,7 +244,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Check if billing is enabled @@ -283,7 +282,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingLimits(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); if (!auth.userId) { @@ -307,7 +306,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); if (!auth.userId) { @@ -374,7 +373,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Check if billing is enabled @@ -491,7 +490,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); // Check if billing is enabled @@ -570,7 +569,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); if (!auth.userId) { @@ -594,7 +593,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async resetProject(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); if (!auth.userId) { @@ -668,7 +667,7 @@ export class Users { @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async deleteProject(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); if (!auth.userId) { diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index eda9fa7..64afe64 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor import {WorkflowExecutionStatus} from '@plunk/db'; import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; - -import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {WorkflowService} from '../services/WorkflowService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -18,7 +16,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const search = req.query.search as string | undefined; @@ -38,7 +36,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const eventName = req.query.eventName as string | undefined; try { @@ -61,7 +59,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; if (!workflowId) { @@ -81,7 +79,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const {name, description, eventName, enabled, allowReentry} = req.body; if (!name) { @@ -111,7 +109,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body; @@ -139,7 +137,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; if (!workflowId) { @@ -159,7 +157,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async addStep(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const {type, name, position, config, templateId, autoConnect} = req.body; @@ -191,7 +189,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async updateStep(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const stepId = req.params.stepId; const {name, position, config, templateId} = req.body; @@ -218,7 +216,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteStep(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const stepId = req.params.stepId; @@ -239,7 +237,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async createTransition(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const {fromStepId, toStepId, condition, priority} = req.body; @@ -269,7 +267,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteTransition(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const transitionId = req.params.transitionId; @@ -290,7 +288,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async startExecution(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const {contactId, context} = req.body; @@ -315,7 +313,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async listExecutions(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); @@ -338,7 +336,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getExecution(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const executionId = req.params.executionId; @@ -359,7 +357,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async cancelExecution(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; const executionId = req.params.executionId; @@ -380,7 +378,7 @@ export class Workflows { @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; const workflowId = req.params.id; if (!workflowId) { diff --git a/apps/api/src/exceptions/index.ts b/apps/api/src/exceptions/index.ts index eca6288..ec92f8f 100644 --- a/apps/api/src/exceptions/index.ts +++ b/apps/api/src/exceptions/index.ts @@ -74,9 +74,7 @@ export class NotFound extends HttpException { * @param id Optional resource identifier to include in the message */ public constructor(resource: string, id?: string) { - const message = id - ? `${resource} with ID "${id}" was not found` - : `That ${resource.toLowerCase()} was not found`; + const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`; // Map common resources to specific error codes const errorCodeMap: Record = { diff --git a/apps/api/src/jobs/__tests__/email-processor.test.ts b/apps/api/src/jobs/__tests__/email-processor.test.ts index d1551ca..5342e36 100644 --- a/apps/api/src/jobs/__tests__/email-processor.test.ts +++ b/apps/api/src/jobs/__tests__/email-processor.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; -import type {Prisma} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; +import {toPrismaJson} from '@plunk/types'; import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers'; // Mock MeterService @@ -261,13 +261,13 @@ describe('Email Processor', () => { from: 'test@example.com', status: EmailStatus.PENDING, sourceType: EmailSourceType.TRANSACTIONAL, - attachments: [ + attachments: toPrismaJson([ { filename: 'document.pdf', content: 'base64encodedcontent', contentType: 'application/pdf', }, - ] as unknown as Prisma.InputJsonValue, + ]), }, }); @@ -306,9 +306,7 @@ describe('Email Processor', () => { from: 'test@example.com', status: EmailStatus.PENDING, sourceType: EmailSourceType.TRANSACTIONAL, - attachments: [ - {filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}, - ] as unknown as Prisma.InputJsonValue, + attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]), }, include: { project: true, diff --git a/apps/api/src/jobs/__tests__/scheduled-processor.test.ts b/apps/api/src/jobs/__tests__/scheduled-processor.test.ts index 0bb8ddc..c23b8be 100644 --- a/apps/api/src/jobs/__tests__/scheduled-processor.test.ts +++ b/apps/api/src/jobs/__tests__/scheduled-processor.test.ts @@ -1,6 +1,6 @@ -import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; import {CampaignStatus} from '@plunk/db'; -import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers'; +import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers'; describe('Scheduled Campaign Processor', () => { let projectId: string; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 9090f35..24e4fbc 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -2,18 +2,14 @@ import dayjs from 'dayjs'; import type {NextFunction, Request, Response} from 'express'; import jsonwebtoken from 'jsonwebtoken'; +import type {AuthResponse} from '@plunk/types'; + import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js'; import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js'; import {MembershipService} from '../services/MembershipService.js'; import {ProjectService} from '../services/ProjectService.js'; import {UserService} from '../services/UserService.js'; -export interface AuthResponse { - type: 'jwt' | 'apiKey'; - userId?: string; - projectId: string; -} - /** * Middleware to check if this unsubscribe is authenticated on the dashboard * @param req @@ -129,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu res.locals.auth = { type: 'apiKey', projectId: project.id, - } as AuthResponse; + }; // Check if project is disabled - block write operations if (project.disabled) { @@ -198,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu res.locals.auth = { type: 'apiKey', projectId: project.id, - } as AuthResponse; + }; // Check if project is disabled - block write operations if (project.disabled) { @@ -309,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio type: 'jwt', userId, projectId, - } as AuthResponse; + }; // Check if project is disabled - block write operations if (project?.disabled) { @@ -340,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio */ export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => { try { - const auth = res.locals.auth as AuthResponse; + const auth = res.locals.auth; if (auth.type === 'apiKey') { return next(); diff --git a/apps/api/src/services/ActivityService.ts b/apps/api/src/services/ActivityService.ts index 2fe1703..2e5ed4d 100644 --- a/apps/api/src/services/ActivityService.ts +++ b/apps/api/src/services/ActivityService.ts @@ -1,6 +1,6 @@ import type {Prisma} from '@plunk/db'; -import {ActivityType} from '@plunk/types'; import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types'; +import {ActivityType} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; diff --git a/apps/api/src/services/AnalyticsService.ts b/apps/api/src/services/AnalyticsService.ts index 917c220..06aefeb 100644 --- a/apps/api/src/services/AnalyticsService.ts +++ b/apps/api/src/services/AnalyticsService.ts @@ -58,7 +58,11 @@ export class AnalyticsService { const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate; // Check cache first - const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString()); + const cacheKey = Keys.Analytics.timeseries( + projectId, + limitedStartDate.toISOString(), + effectiveEndDate.toISOString(), + ); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); @@ -191,7 +195,11 @@ export class AnalyticsService { const effectiveEndDate = endDate || now; // Check cache - const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString()); + const cacheKey = Keys.Analytics.campaignStats( + projectId, + effectiveStartDate.toISOString(), + effectiveEndDate.toISOString(), + ); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); @@ -295,7 +303,12 @@ export class AnalyticsService { const effectiveEndDate = endDate || now; // Check cache - const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString()); + const cacheKey = Keys.Analytics.topEvents( + projectId, + limit, + effectiveStartDate.toISOString(), + effectiveEndDate.toISOString(), + ); const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); diff --git a/apps/api/src/services/BillingLimitService.ts b/apps/api/src/services/BillingLimitService.ts index 4c11f02..ad0bc90 100644 --- a/apps/api/src/services/BillingLimitService.ts +++ b/apps/api/src/services/BillingLimitService.ts @@ -1,5 +1,5 @@ import {EmailSourceType} from '@plunk/db'; -import type {CategoryUsage, BillingLimitsResponse, LimitCheckResult} from '@plunk/types'; +import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types'; import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email'; import React from 'react'; import signale from 'signale'; diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 8815654..ce9c033 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -1,6 +1,7 @@ import type {Campaign, Contact, Prisma} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db'; -import type {FilterCondition, CreateCampaignData, UpdateCampaignData} from '@plunk/types'; +import type {CreateCampaignData, FilterCondition, UpdateCampaignData} from '@plunk/types'; +import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; @@ -59,7 +60,7 @@ export class CampaignService { fromName: data.fromName, replyTo: data.replyTo, audienceType: data.audienceType, - audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue, + audienceCondition: toPrismaJson(data.audienceCondition || null), segmentId: data.segmentId, status: CampaignStatus.DRAFT, totalRecipients: 0, // Will be updated below @@ -107,7 +108,7 @@ export class CampaignService { if (data.audienceCondition) { SegmentService.validateCondition(data.audienceCondition); } - updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue; + updateData.audienceCondition = toPrismaJson(data.audienceCondition || null); } if (data.segmentId !== undefined) { @@ -721,7 +722,7 @@ export class CampaignService { return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); case CampaignAudienceType.FILTERED: { - const condition = campaign.audienceCondition as unknown as FilterCondition; + const condition = fromPrismaJson(campaign.audienceCondition); if (!condition) { throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } @@ -757,7 +758,7 @@ export class CampaignService { throw new HttpException(404, 'Segment not found'); } - const condition = segment.condition as unknown as FilterCondition; + const condition = fromPrismaJson(segment.condition); const segmentWhere = SegmentService.buildConditionClause(condition); return { diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 34233de..7535326 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -1,6 +1,7 @@ import {type Contact, Prisma} from '@plunk/db'; import {isValidLanguageCode} from '@plunk/shared'; -import type {FilterCondition, FilterGroup, CursorPaginatedResponse} from '@plunk/types'; +import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types'; +import {toPrismaJson} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; @@ -229,10 +230,7 @@ export class ContactService { if (key === 'locale') { if (typeof value === 'string') { if (!isValidLanguageCode(value)) { - throw new HttpException( - 400, - `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`, - ); + throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`); } } else if (value !== null && value !== undefined) { throw new HttpException(400, 'Locale must be a string'); @@ -265,7 +263,7 @@ export class ContactService { const updated = await prisma.contact.update({ where: {id: existing.id}, data: { - data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull, + data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, ...(subscribed !== undefined ? {subscribed} : {}), }, }); @@ -285,7 +283,7 @@ export class ContactService { data: { projectId, email, - data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull, + data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, subscribed: subscribed ?? true, }, }); @@ -671,59 +669,11 @@ export class ContactService { return {deletedFrom: result}; } - /** - * Helper: Check if a field is used in a filter condition (recursive) - */ - private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean { - if (!condition || typeof condition !== 'object') { - return false; - } - - // Check groups in the condition - if (Array.isArray(condition.groups)) { - for (const group of condition.groups) { - if (this.fieldUsedInGroup(field, group)) { - return true; - } - } - } - - return false; - } - - /** - * Helper: Check if a field is used in a filter group (recursive) - */ - private static fieldUsedInGroup(field: string, group: FilterGroup): boolean { - if (!group || typeof group !== 'object') { - return false; - } - - // Check filters in the group - if (Array.isArray(group.filters)) { - for (const filter of group.filters) { - if (filter.field === field) { - return true; - } - } - } - - // Check nested conditions - if (group.conditions) { - return this.fieldUsedInCondition(field, group.conditions); - } - - return false; - } - /** * Bulk subscribe contacts * Updates multiple contacts to subscribed=true in batches */ - public static async bulkSubscribe( - projectId: string, - contactIds: string[], - ): Promise<{updated: number}> { + public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> { // Verify all contacts belong to this project const contacts = await prisma.contact.findMany({ where: { @@ -772,10 +722,7 @@ export class ContactService { /** * Bulk unsubscribe contacts */ - public static async bulkUnsubscribe( - projectId: string, - contactIds: string[], - ): Promise<{updated: number}> { + public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> { const contacts = await prisma.contact.findMany({ where: { id: {in: contactIds}, @@ -822,10 +769,7 @@ export class ContactService { /** * Bulk delete contacts */ - public static async bulkDelete( - projectId: string, - contactIds: string[], - ): Promise<{deleted: number}> { + public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> { const result = await prisma.contact.deleteMany({ where: { id: {in: contactIds}, @@ -836,6 +780,51 @@ export class ContactService { return {deleted: result.count}; } + /** + * Helper: Check if a field is used in a filter condition (recursive) + */ + private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean { + if (!condition || typeof condition !== 'object') { + return false; + } + + // Check groups in the condition + if (Array.isArray(condition.groups)) { + for (const group of condition.groups) { + if (this.fieldUsedInGroup(field, group)) { + return true; + } + } + } + + return false; + } + + /** + * Helper: Check if a field is used in a filter group (recursive) + */ + private static fieldUsedInGroup(field: string, group: FilterGroup): boolean { + if (!group || typeof group !== 'object') { + return false; + } + + // Check filters in the group + if (Array.isArray(group.filters)) { + for (const filter of group.filters) { + if (filter.field === field) { + return true; + } + } + } + + // Check nested conditions + if (group.conditions) { + return this.fieldUsedInCondition(field, group.conditions); + } + + return false; + } + /** * Track events sequentially to avoid database deadlocks * Processes events one at a time with error handling diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 2f82a54..24ce916 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -1,11 +1,12 @@ import type {Contact, Email, Prisma, Project} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; +import {toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; -import {renderTemplate, createTranslatorSync} from '@plunk/shared'; +import {createTranslatorSync, renderTemplate} from '@plunk/shared'; import {BillingLimitService} from './BillingLimitService.js'; import {DomainService} from './DomainService.js'; @@ -91,8 +92,8 @@ export class EmailService { fromName: params.fromName, toName: params.toName, replyTo: params.replyTo, - headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, - attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, + headers: params.headers ? toPrismaJson(params.headers) : undefined, + attachments: params.attachments ? toPrismaJson(params.attachments) : undefined, sourceType: EmailSourceType.TRANSACTIONAL, templateId: params.templateId, status: EmailStatus.PENDING, @@ -152,8 +153,8 @@ export class EmailService { from: params.from, fromName: params.fromName, replyTo: params.replyTo, - headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, - attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, + headers: params.headers ? toPrismaJson(params.headers) : undefined, + attachments: params.attachments ? toPrismaJson(params.attachments) : undefined, sourceType, templateId: params.templateId, campaignId: params.campaignId, @@ -213,8 +214,8 @@ export class EmailService { from: params.from, fromName: params.fromName, replyTo: params.replyTo, - headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, - attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, + headers: params.headers ? toPrismaJson(params.headers) : undefined, + attachments: params.attachments ? toPrismaJson(params.attachments) : undefined, sourceType, templateId: params.templateId, workflowExecutionId: params.workflowExecutionId, @@ -250,8 +251,8 @@ export class EmailService { from: params.from, fromName: params.fromName, replyTo: params.replyTo, - headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, - attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, + headers: params.headers ? toPrismaJson(params.headers) : undefined, + attachments: params.attachments ? toPrismaJson(params.attachments) : undefined, sourceType, templateId: params.templateId, workflowExecutionId: params.workflowExecutionId, @@ -509,7 +510,7 @@ export class EmailService { contactId: email.contactId, emailId: email.id, name: `email.${eventType}`, - data: metadata ? (metadata as Prisma.InputJsonValue) : undefined, + data: metadata ? toPrismaJson(metadata) : undefined, }, }); } diff --git a/apps/api/src/services/EmailVerificationService.ts b/apps/api/src/services/EmailVerificationService.ts index a271662..1b2af63 100644 --- a/apps/api/src/services/EmailVerificationService.ts +++ b/apps/api/src/services/EmailVerificationService.ts @@ -11,59 +11,6 @@ const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily) export class EmailVerificationService { private static disposableDomainsSet: Set | null = null; - /** - * Fetch and cache the disposable domains list from GitHub - * Uses Redis for caching with 24-hour TTL - * Falls back to in-memory cache if Redis fails - */ - private static async getDisposableDomains(): Promise> { - // Return in-memory cache if available - if (this.disposableDomainsSet) { - return this.disposableDomainsSet; - } - - try { - // Try to get from Redis cache first - const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY); - if (cached) { - const domains = JSON.parse(cached) as string[]; - this.disposableDomainsSet = new Set(domains); - return this.disposableDomainsSet; - } - - // Fetch from GitHub if not in cache - const response = await fetch(DISPOSABLE_DOMAINS_URL); - if (!response.ok) { - throw new Error(`Failed to fetch disposable domains: ${response.statusText}`); - } - - const text = await response.text(); - const domains = text - .split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')); // Filter empty lines and comments - - // Cache in Redis - await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS); - - // Cache in memory - this.disposableDomainsSet = new Set(domains); - return this.disposableDomainsSet; - } catch (error) { - console.error('Error fetching disposable domains:', error); - // Return empty set as fallback - don't block email verification - return new Set(); - } - } - - /** - * Check if a domain is disposable - */ - private static async isDisposableDomain(domain: string): Promise { - const disposableDomains = await this.getDisposableDomains(); - return disposableDomains.has(domain.toLowerCase()); - } - /** * Verify an email address * - Checks if domain exists (DNS A/AAAA records) @@ -146,4 +93,57 @@ export class EmailVerificationService { return result; } + + /** + * Fetch and cache the disposable domains list from GitHub + * Uses Redis for caching with 24-hour TTL + * Falls back to in-memory cache if Redis fails + */ + private static async getDisposableDomains(): Promise> { + // Return in-memory cache if available + if (this.disposableDomainsSet) { + return this.disposableDomainsSet; + } + + try { + // Try to get from Redis cache first + const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY); + if (cached) { + const domains = JSON.parse(cached) as string[]; + this.disposableDomainsSet = new Set(domains); + return this.disposableDomainsSet; + } + + // Fetch from GitHub if not in cache + const response = await fetch(DISPOSABLE_DOMAINS_URL); + if (!response.ok) { + throw new Error(`Failed to fetch disposable domains: ${response.statusText}`); + } + + const text = await response.text(); + const domains = text + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); // Filter empty lines and comments + + // Cache in Redis + await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS); + + // Cache in memory + this.disposableDomainsSet = new Set(domains); + return this.disposableDomainsSet; + } catch (error) { + console.error('Error fetching disposable domains:', error); + // Return empty set as fallback - don't block email verification + return new Set(); + } + } + + /** + * Check if a domain is disposable + */ + private static async isDisposableDomain(domain: string): Promise { + const disposableDomains = await this.getDisposableDomains(); + return disposableDomains.has(domain.toLowerCase()); + } } diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index 05e4364..af678fc 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -1,6 +1,7 @@ import type {Event} from '@plunk/db'; import {Prisma} from '@plunk/db'; import type {FilterCondition, FilterGroup} from '@plunk/types'; +import {toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; @@ -32,7 +33,7 @@ export class EventService { contactId, emailId, name: eventName, - data: data ? (data as Prisma.InputJsonValue) : undefined, + data: data ? toPrismaJson(data) : undefined, }, }); @@ -472,7 +473,7 @@ export class EventService { contactId, status: 'RUNNING', currentStepId: triggerStep.id, - context: context ? (context as Prisma.InputJsonValue) : undefined, + context: context ? toPrismaJson(context) : undefined, }, }); diff --git a/apps/api/src/services/MembershipService.ts b/apps/api/src/services/MembershipService.ts index d8d91f1..b0f36ea 100644 --- a/apps/api/src/services/MembershipService.ts +++ b/apps/api/src/services/MembershipService.ts @@ -1,5 +1,5 @@ -import type {Membership, Role} from '@plunk/db'; -import type {MemberWithEmail, OwnerInfo, DisabledProjectInfo} from '@plunk/types'; +import type {Membership} from '@plunk/db'; +import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js'; @@ -141,7 +141,7 @@ export class MembershipService { }, }); - return memberships.map((m) => ({ + return memberships.map(m => ({ userId: m.userId, email: m.user.email, role: m.role, @@ -193,11 +193,7 @@ export class MembershipService { * Add a member to a project * Invalidates cache for the project */ - public static async addMember( - projectId: string, - userId: string, - role: 'ADMIN' | 'MEMBER', - ): Promise { + public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise { // Check if membership already exists const existingMembership = await prisma.membership.findUnique({ where: { @@ -232,11 +228,7 @@ export class MembershipService { * Throws if trying to change OWNER role * Invalidates cache */ - public static async updateRole( - projectId: string, - userId: string, - newRole: 'ADMIN' | 'MEMBER', - ): Promise { + public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise { // Get existing membership const existingMembership = await prisma.membership.findUnique({ where: { @@ -341,7 +333,7 @@ export class MembershipService { return { hasDisabledProject: disabledMemberships.length > 0, - disabledProjectNames: disabledMemberships.map((m) => m.project.name), + disabledProjectNames: disabledMemberships.map(m => m.project.name), }; } diff --git a/apps/api/src/services/NtfyService.ts b/apps/api/src/services/NtfyService.ts index cf00d99..ae971c3 100644 --- a/apps/api/src/services/NtfyService.ts +++ b/apps/api/src/services/NtfyService.ts @@ -1,4 +1,4 @@ -import {NtfyPriority, NtfyTag, type NtfyNotification} from '@plunk/types'; +import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types'; import signale from 'signale'; /** diff --git a/apps/api/src/services/QueueService.ts b/apps/api/src/services/QueueService.ts index 0dd0787..f1c1d92 100644 --- a/apps/api/src/services/QueueService.ts +++ b/apps/api/src/services/QueueService.ts @@ -2,15 +2,15 @@ import {type Job, Queue} from 'bullmq'; import type {RedisOptions} from 'ioredis'; import signale from 'signale'; import type { - SendEmailJobData, - CampaignBatchJobData, - ScheduledCampaignJobData, - WorkflowStepJobData, - ContactImportJobData, - BulkContactActionJobData, - SegmentCountJobData, - DomainVerificationJobData, ApiRequestCleanupJobData, + BulkContactActionJobData, + CampaignBatchJobData, + ContactImportJobData, + DomainVerificationJobData, + ScheduledCampaignJobData, + SegmentCountJobData, + SendEmailJobData, + WorkflowStepJobData, } from '@plunk/types'; import {REDIS_URL} from '../app/constants.js'; diff --git a/apps/api/src/services/S3Service.ts b/apps/api/src/services/S3Service.ts index d4275d0..0962747 100644 --- a/apps/api/src/services/S3Service.ts +++ b/apps/api/src/services/S3Service.ts @@ -1,21 +1,21 @@ import { - S3Client, - PutObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand, + PutObjectCommand, + S3Client, } from '@aws-sdk/client-s3'; import crypto from 'crypto'; import signale from 'signale'; import { - S3_ENDPOINT, S3_ACCESS_KEY_ID, S3_ACCESS_KEY_SECRET, S3_BUCKET, - S3_PUBLIC_URL, - S3_FORCE_PATH_STYLE, S3_ENABLED, + S3_ENDPOINT, + S3_FORCE_PATH_STYLE, + S3_PUBLIC_URL, } from '../app/constants.js'; /** diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index aa4f235..317e330 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -1,5 +1,6 @@ import {type Contact, Prisma, type Segment} from '@plunk/db'; -import type {FilterCondition, FilterGroup, SegmentFilter, PaginatedResponse} from '@plunk/types'; +import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types'; +import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; @@ -66,7 +67,7 @@ export class SegmentService { pageSize = 20, ): Promise> { const segment = await this.get(projectId, segmentId); - const condition = segment.condition as unknown as FilterCondition; + const condition = fromPrismaJson(segment.condition); const where = this.buildWhereClause(projectId, condition); const skip = (page - 1) * pageSize; @@ -114,7 +115,7 @@ export class SegmentService { projectId, name: data.name, description: data.description, - condition: data.condition as unknown as Prisma.InputJsonValue, + condition: toPrismaJson(data.condition), trackMembership: data.trackMembership ?? false, memberCount, }, @@ -161,7 +162,7 @@ export class SegmentService { updateData.description = data.description; } if (data.condition !== undefined) { - updateData.condition = data.condition as unknown as Prisma.InputJsonValue; + updateData.condition = toPrismaJson(data.condition); // Recompute member count when condition changes const where = this.buildWhereClause(projectId, data.condition); @@ -229,7 +230,7 @@ export class SegmentService { */ public static async refreshMemberCount(projectId: string, segmentId: string): Promise { const segment = await this.get(projectId, segmentId); - const condition = segment.condition as unknown as FilterCondition; + const condition = fromPrismaJson(segment.condition); const where = this.buildWhereClause(projectId, condition); const memberCount = await prisma.contact.count({where}); @@ -260,7 +261,7 @@ export class SegmentService { await Promise.all( batch.map(async segment => { try { - const condition = segment.condition as unknown as FilterCondition; + const condition = fromPrismaJson(segment.condition); const where = this.buildWhereClause(projectId, condition); const memberCount = await prisma.contact.count({where}); @@ -290,7 +291,7 @@ export class SegmentService { throw new HttpException(400, 'Segment does not have membership tracking enabled'); } - const condition = segment.condition as unknown as FilterCondition; + const condition = fromPrismaJson(segment.condition); const where = this.buildWhereClause(projectId, condition); // Get all matching contacts using cursor-based pagination to avoid memory issues @@ -488,6 +489,19 @@ export class SegmentService { } } + /** + * Build Prisma clause from filter condition (recursive) + */ + public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput { + const groupClauses = condition.groups.map(group => this.buildGroupClause(group)); + + if (condition.logic === 'AND') { + return {AND: groupClauses}; + } else { + return {OR: groupClauses}; + } + } + /** * Validate filter group (recursive) */ @@ -586,19 +600,6 @@ export class SegmentService { }; } - /** - * Build Prisma clause from filter condition (recursive) - */ - public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput { - const groupClauses = condition.groups.map(group => this.buildGroupClause(group)); - - if (condition.logic === 'AND') { - return {AND: groupClauses}; - } else { - return {OR: groupClauses}; - } - } - /** * Build Prisma clause from filter group (recursive) */ diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index 44a1b81..d2eda32 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -1,14 +1,15 @@ import type { Contact, Prisma, + Template, + Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, - Template, - Workflow, } from '@plunk/db'; import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db'; -import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared'; +import {toPrismaJson} from '@plunk/types'; +import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; @@ -80,7 +81,9 @@ export class WorkflowExecutionService { signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`); // This is a delayed step - continue with execution } else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) { - signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`); + signale.info( + `[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`, + ); return; // Already completed or cancelled } @@ -241,7 +244,7 @@ export class WorkflowExecutionService { data: { status: StepExecutionStatus.COMPLETED, completedAt: new Date(), - output: result ? (result as Prisma.InputJsonValue) : undefined, + output: result ? toPrismaJson(result) : undefined, }, }); @@ -440,11 +443,11 @@ export class WorkflowExecutionService { data: { status: StepExecutionStatus.COMPLETED, completedAt: new Date(), - output: { + output: toPrismaJson({ eventName, - eventData: data ? (data as Prisma.InputJsonValue) : undefined, + eventData: data ? toPrismaJson(data) : undefined, receivedAt: new Date().toISOString(), - } as Prisma.InputJsonValue, + }), }, }); @@ -866,7 +869,7 @@ export class WorkflowExecutionService { await prisma.contact.update({ where: {id: contact.id}, data: { - data: newData ? (newData as Prisma.InputJsonValue) : undefined, + data: newData ? toPrismaJson(newData) : undefined, }, }); diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 87c759b..9d64c2a 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -1,6 +1,7 @@ -import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db'; +import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {Prisma, WorkflowExecutionStatus} from '@plunk/db'; -import type {PaginatedResponse, WorkflowWithDetails, WorkflowExecutionWithDetails} from '@plunk/types'; +import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types'; +import {toPrismaJson} from '@plunk/types'; import signale from 'signale'; import {prisma} from '../database/prisma.js'; @@ -15,7 +16,12 @@ export class WorkflowService { /** * Get all workflows for a project with pagination */ - public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise> { + public static async list( + projectId: string, + page = 1, + pageSize = 20, + search?: string, + ): Promise> { const skip = (page - 1) * pageSize; const where: Prisma.WorkflowWhereInput = { @@ -299,8 +305,8 @@ export class WorkflowService { workflowId, type: data.type, name: data.name, - position: data.position as Prisma.InputJsonValue, - config: data.config as Prisma.InputJsonValue, + position: toPrismaJson(data.position), + config: toPrismaJson(data.config), templateId: data.templateId, }, }); @@ -381,8 +387,8 @@ export class WorkflowService { const updateData: Prisma.WorkflowStepUpdateInput = {}; if (data.name !== undefined) updateData.name = data.name; - if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue; - if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue; + if (data.position !== undefined) updateData.position = toPrismaJson(data.position); + if (data.config !== undefined) updateData.config = toPrismaJson(data.config); if (data.templateId !== undefined) { if (data.templateId === null) { updateData.template = {disconnect: true}; @@ -574,7 +580,7 @@ export class WorkflowService { fromStepId: data.fromStepId, condition: { path: ['branch'], - equals: conditionObj.branch as Prisma.InputJsonValue, + equals: toPrismaJson(conditionObj.branch), }, }, }); diff --git a/apps/api/src/services/__tests__/BillingLimitService.test.ts b/apps/api/src/services/__tests__/BillingLimitService.test.ts index 7a7d33d..897bfd7 100644 --- a/apps/api/src/services/__tests__/BillingLimitService.test.ts +++ b/apps/api/src/services/__tests__/BillingLimitService.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {EmailSourceType} from '@plunk/db'; import {BillingLimitService} from '../BillingLimitService'; import {EmailService} from '../EmailService'; diff --git a/apps/api/src/services/__tests__/CampaignService.test.ts b/apps/api/src/services/__tests__/CampaignService.test.ts index 40f62fc..d6ea1e4 100644 --- a/apps/api/src/services/__tests__/CampaignService.test.ts +++ b/apps/api/src/services/__tests__/CampaignService.test.ts @@ -1,5 +1,5 @@ -import {describe, it, expect, beforeEach, vi} from 'vitest'; -import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignService} from '../CampaignService'; import {factories, getPrismaClient} from '../../../../../test/helpers'; diff --git a/apps/api/src/services/__tests__/ContactService.test.ts b/apps/api/src/services/__tests__/ContactService.test.ts index 5f0621e..44b12a1 100644 --- a/apps/api/src/services/__tests__/ContactService.test.ts +++ b/apps/api/src/services/__tests__/ContactService.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach} from 'vitest'; +import {beforeEach, describe, expect, it} from 'vitest'; import {ContactService} from '../ContactService'; import {factories, getPrismaClient} from '../../../../../test/helpers'; @@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => { const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false}); const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false}); - await ContactService.bulkSubscribe(project1.id, [ - p1Contact1.id, - p1Contact2.id, - p2Contact1.id, - p2Contact2.id, - ]); + await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]); const p1ContactsAfter = await prisma.contact.findMany({ where: {projectId: project1.id}, diff --git a/apps/api/src/services/__tests__/DomainService.test.ts b/apps/api/src/services/__tests__/DomainService.test.ts index edf6027..1d441fd 100644 --- a/apps/api/src/services/__tests__/DomainService.test.ts +++ b/apps/api/src/services/__tests__/DomainService.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach, vi} from 'vitest'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; import {factories, getPrismaClient} from '../../../../../test/helpers'; import {DomainService} from '../DomainService.js'; import {HttpException} from '../../exceptions/index.js'; @@ -110,9 +110,7 @@ describe('DomainService', () => { it('should throw error for invalid email format', async () => { const {project} = await factories.createUserWithProject(); - await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( - HttpException, - ); + await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException); await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( /invalid email format/i, @@ -122,13 +120,13 @@ describe('DomainService', () => { it('should throw error when domain is not registered', async () => { const {project} = await factories.createUserWithProject(); - await expect( - DomainService.verifyEmailDomain('sender@unregistered.com', project.id), - ).rejects.toThrow(HttpException); + await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow( + HttpException, + ); - await expect( - DomainService.verifyEmailDomain('sender@unregistered.com', project.id), - ).rejects.toThrow(/not registered/i); + await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow( + /not registered/i, + ); }); it('should throw error when domain belongs to different project', async () => { @@ -141,13 +139,11 @@ describe('DomainService', () => { data: {verified: true}, }); - await expect( - DomainService.verifyEmailDomain('sender@project1.com', project2.id), - ).rejects.toThrow(HttpException); + await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(HttpException); - await expect( - DomainService.verifyEmailDomain('sender@project1.com', project2.id), - ).rejects.toThrow(/belongs to a different project/i); + await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow( + /belongs to a different project/i, + ); }); it('should throw error when domain is not verified', async () => { @@ -155,13 +151,11 @@ describe('DomainService', () => { await DomainService.addDomain(project.id, 'unverified.com'); - await expect( - DomainService.verifyEmailDomain('sender@unverified.com', project.id), - ).rejects.toThrow(HttpException); + await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(HttpException); - await expect( - DomainService.verifyEmailDomain('sender@unverified.com', project.id), - ).rejects.toThrow(/not verified/i); + await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow( + /not verified/i, + ); }); it('should return domain when all checks pass', async () => { @@ -358,9 +352,9 @@ describe('DomainService', () => { }); it('should throw error for non-existent domain', async () => { - await expect( - DomainService.checkVerification('00000000-0000-0000-0000-000000000000'), - ).rejects.toThrow(/domain not found/i); + await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow( + /domain not found/i, + ); }); }); @@ -391,9 +385,7 @@ describe('DomainService', () => { await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException); - await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( - /used in.*template/i, - ); + await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i); }); it('should throw error when domain is used in active campaigns', async () => { @@ -409,9 +401,7 @@ describe('DomainService', () => { await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException); - await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( - /used in.*campaign/i, - ); + await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i); }); it('should allow removal when campaign is SENT (completed)', async () => { @@ -433,9 +423,9 @@ describe('DomainService', () => { }); it('should throw error for non-existent domain', async () => { - await expect( - DomainService.removeDomain('00000000-0000-0000-0000-000000000000'), - ).rejects.toThrow(/domain not found/i); + await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow( + /domain not found/i, + ); }); it('should check usage in multiple templates', async () => { @@ -492,17 +482,15 @@ describe('DomainService', () => { expect(result.domain).toBe('mail.example.com'); // Different subdomain should fail - await expect( - DomainService.verifyEmailDomain('sender@other.example.com', project.id), - ).rejects.toThrow(/not registered/i); + await expect(DomainService.verifyEmailDomain('sender@other.example.com', project.id)).rejects.toThrow( + /not registered/i, + ); }); it('should handle email with no @ sign', async () => { const {project} = await factories.createUserWithProject(); - await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow( - /invalid email format/i, - ); + await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i); }); it('should handle email with multiple @ signs', async () => { @@ -516,9 +504,7 @@ describe('DomainService', () => { it('should handle empty email string', async () => { const {project} = await factories.createUserWithProject(); - await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow( - /invalid email format/i, - ); + await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i); }); }); @@ -537,11 +523,7 @@ describe('DomainService', () => { ]); expect(results).toHaveLength(3); - expect(results.map(d => d.domain).sort()).toEqual([ - 'concurrent1.com', - 'concurrent2.com', - 'concurrent3.com', - ]); + expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']); }); it('should handle concurrent ownership checks', async () => { diff --git a/apps/api/src/services/__tests__/SegmentService.operators.test.ts b/apps/api/src/services/__tests__/SegmentService.operators.test.ts index f41d317..9822a94 100644 --- a/apps/api/src/services/__tests__/SegmentService.operators.test.ts +++ b/apps/api/src/services/__tests__/SegmentService.operators.test.ts @@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match exact string values in standard fields (case-insensitive)', async () => { @@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match boolean values', async () => { @@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match numeric values as strings in JSON fields', async () => { @@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should exclude boolean false values', async () => { @@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should NOT include contacts where field does not exist (only excludes matching values)', async () => { @@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); // notEquals only matches where field exists and has different value expect(ids).toContain(withDifferentValue.id); expect(ids).not.toContain(withMatchingField.id); @@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match substring in email field (case-insensitive)', async () => { @@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match1.id); expect(ids).toContain(match2.id); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); it('should not match when field does not exist', async () => { @@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should match partial domain in email', async () => { @@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(gmailUser.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(gmailUser.id); }); }); @@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => { @@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); // notContains only matches where field exists and doesn't contain substring expect(ids).toContain(withDifferentValue.id); expect(ids).not.toContain(withMatchingSubstring.id); @@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); }); @@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(high.id); expect(ids).toContain(veryHigh.id); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); it('should exclude values equal to threshold', async () => { @@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should work with negative numbers', async () => { @@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should work with decimal values', async () => { @@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(equal.id); expect(ids).toContain(greater.id); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); }); @@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(low.id); expect(ids).toContain(veryLow.id); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); it('should exclude values equal to threshold', async () => { @@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); }); @@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(equal.id); expect(ids).toContain(less.id); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); }); @@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(positive.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(positive.id); }); it('should handle very large numbers', async () => { @@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); }); @@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withField.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withField.id); }); it('should exclude contacts where field is null', async () => { @@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withValue.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withValue.id); }); it('should match fields with empty string values', async () => { @@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withEmptyString.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withEmptyString.id); }); it('should match fields with zero values', async () => { @@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withZero.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withZero.id); }); it('should match fields with boolean false values', async () => { @@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withFalse.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withFalse.id); }); }); @@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withoutField.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withoutField.id); }); it('should match contacts where field is null', async () => { @@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withNull.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withNull.id); }); it('should exclude fields with empty string values', async () => { @@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should exclude fields with zero values', async () => { @@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); }); }); @@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(recent.id); }); @@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(veryRecent.id); }); @@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(justNow.id); }); }); @@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match contacts with JSON date field within specified hours', async () => { @@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match contacts with JSON date field within specified minutes', async () => { @@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should NOT match contacts with JSON date field outside the time range', async () => { @@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should NOT match contacts where JSON date field does not exist', async () => { @@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should handle null values in JSON date fields gracefully', async () => { @@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should work correctly with combined filters (AND logic)', async () => { @@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should require unit parameter for within operator', async () => { @@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(newer.id); expect(ids).not.toContain(older.id); }); @@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(first.id); expect(ids).toContain(second.id); expect(ids).not.toContain(third.id); @@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match1.id); expect(ids).toContain(match2.id); expect(ids).not.toContain(noMatch.id); @@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match1.id); expect(ids).toContain(match2.id); expect(ids).not.toContain(noMatch.id); @@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match.id); }); @@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match1.id); expect(ids).toContain(match2.id); expect(ids).not.toContain(noMatch.id); @@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(match1.id); expect(ids).toContain(match2.id); expect(ids).not.toContain(noMatch.id); @@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should combine existence checks with value comparisons', async () => { @@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should not match contacts who have not triggered the event', async () => { @@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should match contacts with multiple occurrences of the same event', async () => { @@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match contacts with event within time range (hours)', async () => { @@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should match contacts with event within time range (minutes)', async () => { @@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should handle events at exact boundary', async () => { @@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { const result = await SegmentService.getContacts(projectId, segment.id); // Should not match because events at exact boundary are excluded - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should not match contacts with event outside time range', async () => { @@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should match contact if any of their events is within range', async () => { @@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should not match contacts who have triggered the event', async () => { @@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); it('should match contacts with other events but not the target event', async () => { @@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); @@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should combine multiple event filters', async () => { @@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); }); }); diff --git a/apps/api/src/services/__tests__/SegmentService.test.ts b/apps/api/src/services/__tests__/SegmentService.test.ts index 58005ac..5c455e0 100644 --- a/apps/api/src/services/__tests__/SegmentService.test.ts +++ b/apps/api/src/services/__tests__/SegmentService.test.ts @@ -32,8 +32,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(subscribed.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(subscribed.id); }); it('should filter contacts by custom data fields', async () => { @@ -53,8 +53,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(proUser.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(proUser.id); }); it('should filter contacts with multiple conditions', async () => { @@ -85,8 +85,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(target.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(target.id); }); it('should support notEquals operator', async () => { @@ -106,8 +106,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(pro.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(pro.id); }); it('should support contains operator for strings', async () => { @@ -127,8 +127,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(match.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(match.id); }); it('should support exists operator for custom fields', async () => { @@ -148,8 +148,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(withField.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(withField.id); }); it('should handle empty segments', async () => { @@ -165,7 +165,7 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); expect(result.total).toBe(0); }); }); @@ -192,7 +192,7 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); expect(result.total).toBe(2); - expect(result.contacts).toHaveLength(2); + expect(result.data).toHaveLength(2); }); it('should support pagination', async () => { @@ -209,15 +209,15 @@ describe('SegmentService', () => { }); const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10); - expect(page1.contacts).toHaveLength(10); + expect(page1.data).toHaveLength(10); expect(page1.total).toBe(25); expect(page1.totalPages).toBe(3); const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10); - expect(page2.contacts).toHaveLength(10); + expect(page2.data).toHaveLength(10); const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10); - expect(page3.contacts).toHaveLength(5); + expect(page3.data).toHaveLength(5); }); }); @@ -278,7 +278,7 @@ describe('SegmentService', () => { // Initially not in segment let result = await SegmentService.getContacts(projectId, proSegment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); // Update contact to pro plan await prisma.contact.update({ @@ -288,8 +288,8 @@ describe('SegmentService', () => { // Should now be in segment result = await SegmentService.getContacts(projectId, proSegment.id); - expect(result.contacts).toHaveLength(1); - expect(result.contacts[0].id).toBe(contact.id); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe(contact.id); }); it('should be removed from segment when criteria no longer met', async () => { @@ -304,7 +304,7 @@ describe('SegmentService', () => { // Initially in segment let result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(1); + expect(result.data).toHaveLength(1); // Unsubscribe contact await prisma.contact.update({ @@ -314,7 +314,7 @@ describe('SegmentService', () => { // Should no longer be in segment result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts).toHaveLength(0); + expect(result.data).toHaveLength(0); }); }); @@ -506,8 +506,8 @@ describe('SegmentService', () => { const result = await SegmentService.getContacts(projectId, segment.id); - expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort()); - expect(result.contacts.map(c => c.id)).not.toContain(match.id); + expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort()); + expect(result.data.map(c => c.id)).not.toContain(match.id); }); it('should support case-insensitive equals/contains for email strings', async () => { @@ -526,7 +526,7 @@ describe('SegmentService', () => { }); const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id); - const equalsIds = equalsResult.contacts.map(c => c.id); + const equalsIds = equalsResult.data.map(c => c.id); expect(equalsIds).toContain(lower.id); expect(equalsIds).toContain(upper.id); @@ -536,7 +536,7 @@ describe('SegmentService', () => { }); const containsResult = await SegmentService.getContacts(projectId, containsSegment.id); - const containsIds = containsResult.contacts.map(c => c.id); + const containsIds = containsResult.data.map(c => c.id); expect(containsIds).toContain(lower.id); expect(containsIds).toContain(upper.id); }); @@ -557,7 +557,7 @@ describe('SegmentService', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(unsubscribed.id); expect(ids).not.toContain(subscribed.id); @@ -579,7 +579,7 @@ describe('SegmentService', () => { }); const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id); - const notContainsIds = notContainsResult.contacts.map(c => c.id); + const notContainsIds = notContainsResult.data.map(c => c.id); expect(notContainsIds).toContain(other.id); expect(notContainsIds).not.toContain(acme.id); @@ -589,7 +589,7 @@ describe('SegmentService', () => { }); const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id); - const notEqualsIds = notEqualsResult.contacts.map(c => c.id); + const notEqualsIds = notEqualsResult.data.map(c => c.id); expect(notEqualsIds).toContain(other.id); expect(notEqualsIds).not.toContain(acme.id); }); @@ -610,7 +610,7 @@ describe('SegmentService', () => { }); const existsResult = await SegmentService.getContacts(projectId, existsSegment.id); - const existsIds = new Set(existsResult.contacts.map(c => c.id)); + const existsIds = new Set(existsResult.data.map(c => c.id)); expect(existsIds.has(withCompany.id)).toBe(true); expect(existsIds.has(withNullCompany.id)).toBe(false); @@ -620,7 +620,7 @@ describe('SegmentService', () => { }); const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id); - const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id)); + const notExistsIds = new Set(notExistsResult.data.map(c => c.id)); expect(notExistsIds.has(withCompany.id)).toBe(false); expect(notExistsIds.has(withNullCompany.id)).toBe(true); }); @@ -645,7 +645,7 @@ describe('SegmentService', () => { }); const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id); - const gtIds = greaterThanResult.contacts.map(c => c.id); + const gtIds = greaterThanResult.data.map(c => c.id); expect(gtIds).toContain(mid.id); expect(gtIds).toContain(high.id); expect(gtIds).not.toContain(low.id); @@ -656,7 +656,7 @@ describe('SegmentService', () => { }); const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id); - const lteIds = lteResult.contacts.map(c => c.id); + const lteIds = lteResult.data.map(c => c.id); expect(lteIds).toContain(low.id); expect(lteIds).toContain(mid.id); expect(lteIds).not.toContain(high.id); @@ -674,7 +674,7 @@ describe('SegmentService', () => { }); const gtResult = await SegmentService.getContacts(projectId, gtSegment.id); - const gtIds = gtResult.contacts.map(c => c.id); + const gtIds = gtResult.data.map(c => c.id); expect(gtIds).toContain(newer.id); expect(gtIds).not.toContain(older.id); @@ -684,7 +684,7 @@ describe('SegmentService', () => { }); const lteResult = await SegmentService.getContacts(projectId, lteSegment.id); - const lteIds = lteResult.contacts.map(c => c.id); + const lteIds = lteResult.data.map(c => c.id); expect(lteIds).toContain(older.id); expect(lteIds).toContain(newer.id); }); @@ -705,7 +705,7 @@ describe('SegmentService', () => { }); const result = await SegmentService.getContacts(projectId, segment.id); - const ids = result.contacts.map(c => c.id); + const ids = result.data.map(c => c.id); expect(ids).toContain(recent.id); }); }); diff --git a/apps/api/src/services/__tests__/TemplateService.test.ts b/apps/api/src/services/__tests__/TemplateService.test.ts index 7a50657..f7adc10 100644 --- a/apps/api/src/services/__tests__/TemplateService.test.ts +++ b/apps/api/src/services/__tests__/TemplateService.test.ts @@ -1,4 +1,4 @@ -import {describe, it, expect, beforeEach} from 'vitest'; +import {beforeEach, describe, expect, it} from 'vitest'; import {TemplateType} from '@plunk/db'; import {TemplateService} from '../TemplateService'; import {factories, getPrismaClient} from '../../../../../test/helpers'; @@ -104,18 +104,18 @@ describe('TemplateService', () => { } const page1 = await TemplateService.list(projectId, 1, 10); - expect(page1.templates).toHaveLength(10); + expect(page1.data).toHaveLength(10); expect(page1.total).toBe(25); expect(page1.page).toBe(1); expect(page1.pageSize).toBe(10); expect(page1.totalPages).toBe(3); const page2 = await TemplateService.list(projectId, 2, 10); - expect(page2.templates).toHaveLength(10); + expect(page2.data).toHaveLength(10); expect(page2.page).toBe(2); const page3 = await TemplateService.list(projectId, 3, 10); - expect(page3.templates).toHaveLength(5); + expect(page3.data).toHaveLength(5); expect(page3.page).toBe(3); }); @@ -127,7 +127,7 @@ describe('TemplateService', () => { const result = await TemplateService.list(projectId, 1, 20, 'welcome'); expect(result.total).toBe(2); - expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true); + expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true); }); it('should filter templates by search query (description)', async () => { @@ -165,7 +165,7 @@ describe('TemplateService', () => { const result = await TemplateService.list(projectId, 1, 20, 'new'); expect(result.total).toBe(2); - expect(result.templates.map(t => t.description)).toEqual( + expect(result.data.map(t => t.description)).toEqual( expect.arrayContaining([expect.stringContaining('new')]), ); }); @@ -196,11 +196,11 @@ describe('TemplateService', () => { const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING); expect(marketingResult.total).toBe(2); - expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true); + expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true); const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL); expect(transactionalResult.total).toBe(1); - expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL); + expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL); }); it('should combine search and type filters', async () => { @@ -223,7 +223,7 @@ describe('TemplateService', () => { const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING); expect(result.total).toBe(1); - expect(result.templates[0].name).toBe('Welcome Email'); + expect(result.data[0].name).toBe('Welcome Email'); }); it('should return templates ordered by creation date (newest first)', async () => { @@ -236,9 +236,9 @@ describe('TemplateService', () => { const result = await TemplateService.list(projectId, 1, 20); - expect(result.templates[0].id).toBe(template3.id); // Newest - expect(result.templates[1].id).toBe(template2.id); - expect(result.templates[2].id).toBe(template1.id); // Oldest + expect(result.data[0].id).toBe(template3.id); // Newest + expect(result.data[1].id).toBe(template2.id); + expect(result.data[2].id).toBe(template1.id); // Oldest }); it('should only return templates for the specified project', async () => { diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts index e61b8e3..837e928 100644 --- a/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts @@ -1,5 +1,6 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db'; +import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db'; +import {toPrismaJson} from '@plunk/types'; import {WorkflowExecutionService} from '../WorkflowExecutionService'; import {factories, getPrismaClient} from '../../../../../test/helpers'; @@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Premium Status', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'data.isPremium', operator: 'equals', value: true, - }, + }), }, }); @@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Premium Path', position: {x: 200, y: -50}, - config: {reason: 'Premium customer'}, + config: toPrismaJson({reason: 'Premium customer'}), }, }); @@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Standard Path', position: {x: 200, y: 50}, - config: {reason: 'Standard customer'}, + config: toPrismaJson({reason: 'Standard customer'}), }, }); @@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: yesStep.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), priority: 1, }, }); @@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: noStep.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), priority: 2, }, }); @@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Premium', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'data.isPremium', operator: 'equals', value: true, - }, + }), }, }); @@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Premium', position: {x: 200, y: -50}, - config: {}, + config: toPrismaJson({}), }, }); @@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Standard', position: {x: 200, y: 50}, - config: {}, + config: toPrismaJson({}), }, }); @@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: yesStep.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: noStep.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), }, }); @@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Country', position: {x: 100, y: 0}, - config: {field: 'data.country', operator: 'equals', value: 'US'}, + config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}), }, }); @@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Premium (US)', position: {x: 200, y: -50}, - config: {field: 'data.isPremium', operator: 'equals', value: true}, + config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}), }, }); @@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'US Premium', position: {x: 300, y: -75}, - config: {}, + config: toPrismaJson({}), }, }); @@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'US Standard', position: {x: 300, y: -25}, - config: {}, + config: toPrismaJson({}), }, }); @@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Non-US', position: {x: 200, y: 50}, - config: {}, + config: toPrismaJson({}), }, }); @@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition1.id, toStepId: condition2.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition1.id, toStepId: nonUsExit.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), }, }); @@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition2.id, toStepId: usPremiumExit.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition2.id, toStepId: usStandardExit.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), }, }); @@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.WAIT_FOR_EVENT, name: 'Wait for Purchase', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ eventName: 'purchase.completed', timeout: 3600, // 1 hour - }, + }), }, }); @@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Complete', position: {x: 200, y: 0}, - config: {}, + config: toPrismaJson({}), }, }); @@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.WAIT_FOR_EVENT, name: 'Wait for Event', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ eventName: 'user.verified', timeout: 3600, - }, + }), }, }); @@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Done', position: {x: 200, y: 0}, - config: {}, + config: toPrismaJson({}), }, }); @@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.DELAY, name: 'Wait 1 day', position: {x: 100, y: 0}, - config: {amount: 1, unit: 'days'}, + config: toPrismaJson({amount: 1, unit: 'days'}), }, }); @@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Status', position: {x: 200, y: 0}, - config: {field: 'contact.subscribed', operator: 'equals', value: true}, + config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}), }, }); @@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Complete', position: {x: 300, y: 0}, - config: {}, + config: toPrismaJson({}), }, }); @@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition.id, toStepId: exit.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'A/B Split', position: {x: 100, y: 0}, - config: {field: 'data.segment', operator: 'equals', value: 'A'}, + config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}), }, }); @@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.DELAY, name: 'Path A Delay', position: {x: 200, y: -50}, - config: {amount: 1, unit: 'hours'}, + config: toPrismaJson({amount: 1, unit: 'hours'}), }, }); @@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.DELAY, name: 'Path B Delay', position: {x: 200, y: 50}, - config: {amount: 2, unit: 'hours'}, + config: toPrismaJson({amount: 2, unit: 'hours'}), }, }); @@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Merge Point', position: {x: 300, y: 0}, - config: {}, + config: toPrismaJson({}), }, }); @@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition.id, toStepId: pathA.id, - condition: {branch: 'yes'}, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition.id, toStepId: pathB.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), }, }); @@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Bad Condition', position: {x: 100, y: 0}, - config: {}, // Invalid - missing required fields + config: toPrismaJson({}), // Invalid - missing required fields }, }); @@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check Missing Field', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'data.nonExistentField', operator: 'equals', value: 'something', - }, + }), }, }); @@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Exit', position: {x: 200, y: 0}, - config: {}, + config: toPrismaJson({}), }, }); @@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: condition.id, toStepId: noStep.id, - condition: {branch: 'no'}, + condition: toPrismaJson({branch: 'no'}), }, }); @@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Early Exit', position: {x: 100, y: 0}, - config: {reason: 'User already converted'}, + config: toPrismaJson({reason: 'User already converted'}), }, }); @@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: {}, + context: toPrismaJson({}), }, }); @@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.TRIGGER, name: 'Start', position: {x: 0, y: 0}, - config: {}, + config: toPrismaJson({}), }); const exitStep = await factories.createWorkflowStep({ @@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'End', position: {x: 100, y: 0}, - config: {}, + config: toPrismaJson({}), }); await prisma.workflowTransition.create({ @@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep.id, - context: contextData as Prisma.InputJsonValue, + context: toPrismaJson(contextData), }, }); @@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check if first open', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'event.isFirstOpen', operator: 'equals', value: true, // Use boolean, not string - } as Prisma.InputJsonValue, + }), }, }); @@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'First Open', position: {x: 200, y: 0}, - config: {reason: 'first_open'} as Prisma.InputJsonValue, + config: toPrismaJson({reason: 'first_open'}), }, }); @@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Not First Open', position: {x: 200, y: 100}, - config: {reason: 'not_first_open'} as Prisma.InputJsonValue, + config: toPrismaJson({reason: 'not_first_open'}), }, }); @@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id}, }); await prisma.workflowTransition.create({ - data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue}, + data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})}, }); await prisma.workflowTransition.create({ - data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue}, + data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})}, }); // Create execution with event data @@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep!.id, - context: { + context: toPrismaJson({ subject: 'Welcome Email', from: 'hello@example.com', isFirstOpen: true, openedAt: new Date().toISOString(), - } as Prisma.InputJsonValue, + }), }, }); @@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check subject', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'event.subject', operator: 'contains', value: 'Welcome', - } as Prisma.InputJsonValue, + }), }, }); @@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Done', position: {x: 200, y: 0}, - config: {reason: 'matched'} as Prisma.InputJsonValue, + config: toPrismaJson({reason: 'matched'}), }, }); @@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: exitStep.id, - condition: {branch: 'yes'} as Prisma.InputJsonValue, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep!.id, - context: { + context: toPrismaJson({ subject: 'Welcome to Plunk!', from: 'team@plunk.com', - } as Prisma.InputJsonValue, + }), }, }); @@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.CONDITION, name: 'Check opens count', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ field: 'event.opens', operator: 'greaterThan', value: '3', - } as Prisma.InputJsonValue, + }), }, }); @@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.EXIT, name: 'Done', position: {x: 200, y: 0}, - config: {reason: 'engaged'} as Prisma.InputJsonValue, + config: toPrismaJson({reason: 'engaged'}), }, }); @@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => { data: { fromStepId: conditionStep.id, toStepId: exitStep.id, - condition: {branch: 'yes'} as Prisma.InputJsonValue, + condition: toPrismaJson({branch: 'yes'}), }, }); @@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep!.id, - context: { + context: toPrismaJson({ subject: 'Newsletter', opens: 5, - } as Prisma.InputJsonValue, + }), }, }); @@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => { type: WorkflowStepType.WEBHOOK, name: 'Send Webhook', position: {x: 100, y: 0}, - config: { + config: toPrismaJson({ url: 'https://webhook.example.com/test', method: 'POST', - } as Prisma.InputJsonValue, + }), }, }); @@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => { contactId: contact.id, status: WorkflowExecutionStatus.RUNNING, currentStepId: triggerStep!.id, - context: { + context: toPrismaJson({ subject: 'Welcome Email', from: 'hello@example.com', messageId: 'msg-123', isFirstOpen: true, openedAt: '2024-01-15T10:00:00Z', - } as Prisma.InputJsonValue, + }), }, }); diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.test.ts index 5d69779..94a07ba 100644 --- a/apps/api/src/services/__tests__/WorkflowExecutionService.test.ts +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.test.ts @@ -1,9 +1,9 @@ -import {describe, it, expect, beforeEach} from 'vitest'; +import {beforeEach, describe, expect, it} from 'vitest'; import { - WorkflowStepType, StepExecutionStatus, - WorkflowExecutionStatus, TemplateType, + WorkflowExecutionStatus, + WorkflowStepType, WorkflowTriggerType, } from '@plunk/db'; import {WorkflowExecutionService} from '../WorkflowExecutionService'; diff --git a/apps/api/src/services/__tests__/WorkflowService.test.ts b/apps/api/src/services/__tests__/WorkflowService.test.ts index f2c4438..7282bce 100644 --- a/apps/api/src/services/__tests__/WorkflowService.test.ts +++ b/apps/api/src/services/__tests__/WorkflowService.test.ts @@ -205,7 +205,7 @@ describe('WorkflowService', () => { const page1 = await WorkflowService.list(projectId, 1, 10); - expect(page1.workflows).toHaveLength(10); + expect(page1.data).toHaveLength(10); expect(page1.total).toBe(25); expect(page1.totalPages).toBe(3); }); @@ -218,7 +218,7 @@ describe('WorkflowService', () => { const result = await WorkflowService.list(projectId, 1, 20, 'welcome'); expect(result.total).toBe(2); - expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true); + expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true); }); it('should include step and execution counts', async () => { @@ -234,8 +234,8 @@ describe('WorkflowService', () => { const result = await WorkflowService.list(projectId); - const found = result.workflows.find(w => w.id === workflow.id) as - | ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}}) + const found = result.data.find(w => w.id === workflow.id) as + | ((typeof result.data)[number] & {_count: {steps: number; executions: number}}) | undefined; expect(found?._count.steps).toBe(3); // TRIGGER + 2 added expect(found?._count.executions).toBe(1); diff --git a/apps/landing/src/lib/emailHtmlConverter.ts b/apps/landing/src/lib/emailHtmlConverter.ts index 9961f43..d38c680 100644 --- a/apps/landing/src/lib/emailHtmlConverter.ts +++ b/apps/landing/src/lib/emailHtmlConverter.ts @@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string { const fragment = convertToEmailHtml(html); return wrapEmailHtml(fragment); } - diff --git a/apps/landing/src/pages/api/verify-email.ts b/apps/landing/src/pages/api/verify-email.ts index ac52937..a193703 100644 --- a/apps/landing/src/pages/api/verify-email.ts +++ b/apps/landing/src/pages/api/verify-email.ts @@ -18,10 +18,7 @@ interface ErrorResponse { error: string; } -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default async function handler(req: NextApiRequest, res: NextApiResponse) { // Only allow POST requests if (req.method !== 'POST') { return res.status(405).json({error: 'Method not allowed'}); @@ -49,7 +46,7 @@ export default async function handler( method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${secretKey}`, + 'Authorization': `Bearer ${secretKey}`, }, body: JSON.stringify({email}), }); diff --git a/apps/smtp/README.md b/apps/smtp/README.md index b6dce5d..0de95b4 100644 --- a/apps/smtp/README.md +++ b/apps/smtp/README.md @@ -1,6 +1,7 @@ # Plunk SMTP Relay Server -A production-ready SMTP relay server that accepts emails via SMTP protocol and forwards them to the Plunk API's `/v1/send` endpoint. +A production-ready SMTP relay server that accepts emails via SMTP protocol and forwards them to the Plunk API's +`/v1/send` endpoint. ## Features @@ -19,6 +20,7 @@ SMTP Client → SMTP Server → Email Parser → API /v1/send → AWS SES ``` The SMTP server acts as a relay: + 1. Accepts SMTP connections with authentication 2. Validates sender domains against the database 3. Parses incoming emails @@ -27,15 +29,15 @@ The SMTP server acts as a relay: ## Environment Variables -| Variable | Default | Description | -|----------|---------|-------------| -| `API_URI` | `http://localhost:3000` | Plunk API base URL | -| `SMTP_DOMAIN` | *(empty)* | SMTP domain - required when using Traefik acme.json with multiple certificates | -| `PORT_SECURE` | `465` | SMTPS port (implicit TLS) | -| `PORT_SUBMISSION` | `587` | SMTP submission port (STARTTLS) | -| `MAX_RECIPIENTS` | `5` | Maximum recipients per email | -| `CERT_PATH` | `/certs` | Path to certificate files | -| `ACME_JSON_PATH` | `/certs/acme.json` | Path to Traefik acme.json file | +| Variable | Default | Description | +|-------------------|-------------------------|--------------------------------------------------------------------------------| +| `API_URI` | `http://localhost:3000` | Plunk API base URL | +| `SMTP_DOMAIN` | *(empty)* | SMTP domain - required when using Traefik acme.json with multiple certificates | +| `PORT_SECURE` | `465` | SMTPS port (implicit TLS) | +| `PORT_SUBMISSION` | `587` | SMTP submission port (STARTTLS) | +| `MAX_RECIPIENTS` | `5` | Maximum recipients per email | +| `CERT_PATH` | `/certs` | Path to certificate files | +| `ACME_JSON_PATH` | `/certs/acme.json` | Path to Traefik acme.json file | See `.env.self-host.example` in the repository root for full configuration options. @@ -57,7 +59,8 @@ yarn install yarn workspace smtp dev ``` -The SMTP server will start on ports 465 and 587. For local testing without TLS certificates, it will run in plaintext mode on port 587. +The SMTP server will start on ports 465 and 587. For local testing without TLS certificates, it will run in plaintext +mode on port 587. ### Testing with Telnet @@ -85,6 +88,7 @@ QUIT ### Testing with Mail Clients Configure your email client with: + - **SMTP Server**: `localhost` (or your domain in production) - **Port**: 587 (STARTTLS) or 465 (SSL/TLS) - **Username**: `plunk` @@ -98,14 +102,14 @@ Configure your email client with: The SMTP server supports TLS certificates through two methods: 1. **Traefik acme.json** (recommended for Traefik/Dokploy users) - - Mount your Traefik acme.json file to `/certs/acme.json` - - Set `SMTP_DOMAIN` environment variable to select the correct certificate - - The server will automatically use the certificate for your domain + - Mount your Traefik acme.json file to `/certs/acme.json` + - Set `SMTP_DOMAIN` environment variable to select the correct certificate + - The server will automatically use the certificate for your domain 2. **PEM Files** (standard certificate files) - - Mount `privkey.pem` and `fullchain.pem` to `/certs/` - - These are standard Let's Encrypt/Certbot filenames - - `SMTP_DOMAIN` is optional when using PEM files + - Mount `privkey.pem` and `fullchain.pem` to `/certs/` + - These are standard Let's Encrypt/Certbot filenames + - `SMTP_DOMAIN` is optional when using PEM files If no certificates are mounted, the server will run without TLS (not recommended for production). @@ -114,6 +118,7 @@ If no certificates are mounted, the server will run without TLS (not recommended The SMTP server is included in the main Plunk Docker image: **Option 1: With Traefik acme.json** + ```bash docker run -d \ -p 465:465 \ @@ -128,6 +133,7 @@ docker run -d \ ``` **Option 2: With PEM files** + ```bash docker run -d \ -p 465:465 \ @@ -142,6 +148,7 @@ docker run -d \ ``` **Option 3: Without TLS (Development Only)** + ```bash docker run -d \ -p 587:587 \ @@ -206,6 +213,7 @@ Use PM2 or similar process managers to monitor the service in production. ### TLS Certificate Issues If TLS is not working: + 1. Verify certificates are mounted correctly: ```bash docker exec ls -la /certs/ @@ -221,6 +229,7 @@ If TLS is not working: ### Connection Refused If clients cannot connect: + 1. Verify ports 465 and 587 are exposed and not blocked by firewall 2. Check if the service is running: `pm2 list` 3. Review logs: `pm2 logs smtp` @@ -228,6 +237,7 @@ If clients cannot connect: ### Authentication Failures If authentication fails: + 1. Verify username is exactly `plunk` 2. Verify password is the project secret, not public key 3. Check database connectivity @@ -235,6 +245,7 @@ If authentication fails: ### Domain Verification Errors If emails are rejected with domain errors: + 1. Verify domain is added to your project 2. Check domain verification status in Plunk dashboard 3. Ensure DNS records are properly configured @@ -249,6 +260,7 @@ The SMTP server is designed for high-scale email sending: - **Fast Authentication**: Single database query per connection For high-volume sending: + - Deploy multiple SMTP server instances behind a load balancer - Use connection pooling in your SMTP clients - Monitor API rate limits and adjust accordingly @@ -271,6 +283,7 @@ Authorization: Bearer {project_secret} ``` The API response is translated to SMTP status codes: + - `200 OK` → `250 Message accepted` - `4xx/5xx` → `554 Transaction failed` diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx index 8c623c2..b909ba8 100644 --- a/apps/web/src/components/ActivityFeed.tsx +++ b/apps/web/src/components/ActivityFeed.tsx @@ -1,39 +1,11 @@ import {Button} from '@plunk/ui'; +import type {Activity, CursorPaginatedResponse} from '@plunk/types'; import {network} from '../lib/network'; import {ActivityItem} from './ActivityItem'; import {Loader2} from 'lucide-react'; import {useCallback, useEffect, useMemo, useState} from 'react'; -export enum ActivityType { - EVENT_TRIGGERED = 'event.triggered', - EMAIL_SENT = 'email.sent', - EMAIL_DELIVERED = 'email.delivered', - EMAIL_OPENED = 'email.opened', - EMAIL_CLICKED = 'email.clicked', - EMAIL_BOUNCED = 'email.bounced', - CAMPAIGN_SENT = 'campaign.sent', - CAMPAIGN_SCHEDULED = 'campaign.scheduled', - WORKFLOW_STARTED = 'workflow.started', - WORKFLOW_COMPLETED = 'workflow.completed', - WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled', -} - -export interface Activity { - id: string; - type: ActivityType; - timestamp: string; - contactEmail?: string; - contactId?: string; - metadata: Record; -} - -interface PaginatedActivities { - activities: Activity[]; - nextCursor?: string; - hasMore: boolean; -} - -interface ActivityFeedProps { +export interface ActivityFeedProps { typeFilter?: string; dateRangeDays?: number; contactId?: string; @@ -84,17 +56,17 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi params.set('contactId', contactId); } - const result = await network.fetch('GET', `/activity?${params.toString()}`); + const result = await network.fetch>('GET', `/activity?${params.toString()}`); if (cursor) { // Append to existing activities - setActivities(prev => [...prev, ...result.activities]); + setActivities(prev => [...prev, ...result.data]); } else { // Replace activities - setActivities(result.activities); + setActivities(result.data); } - setNextCursor(result.nextCursor); + setNextCursor(result.cursor); setHasMore(result.hasMore); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load activities'); diff --git a/apps/web/src/components/ActivityItem.tsx b/apps/web/src/components/ActivityItem.tsx index 5f934e0..0152c72 100644 --- a/apps/web/src/components/ActivityItem.tsx +++ b/apps/web/src/components/ActivityItem.tsx @@ -1,5 +1,5 @@ import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui'; -import type {Activity} from './ActivityFeed'; +import type {Activity} from '@plunk/types'; import {memo, useState} from 'react'; import {EmailPreviewModal} from './EmailPreviewModal'; import { diff --git a/apps/web/src/components/BillingConsumption.tsx b/apps/web/src/components/BillingConsumption.tsx index c5bc4d6..1fec316 100644 --- a/apps/web/src/components/BillingConsumption.tsx +++ b/apps/web/src/components/BillingConsumption.tsx @@ -1,5 +1,5 @@ -import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '@plunk/ui'; -import {AlertCircle, TrendingUp, Coins} from 'lucide-react'; +import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui'; +import {AlertCircle, Coins, TrendingUp} from 'lucide-react'; import {useBillingConsumption} from '../lib/hooks/useBillingConsumption'; import {useConfig} from '../lib/hooks/useConfig'; import {useCallback} from 'react'; diff --git a/apps/web/src/components/BillingInvoices.tsx b/apps/web/src/components/BillingInvoices.tsx index d95794f..782ceeb 100644 --- a/apps/web/src/components/BillingInvoices.tsx +++ b/apps/web/src/components/BillingInvoices.tsx @@ -1,4 +1,4 @@ -import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert, Badge, Button} from '@plunk/ui'; +import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui'; import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react'; import {useBillingInvoices} from '../lib/hooks/useBillingInvoices'; diff --git a/apps/web/src/components/CampaignSelectionDialog.tsx b/apps/web/src/components/CampaignSelectionDialog.tsx index e482c80..f5ba650 100644 --- a/apps/web/src/components/CampaignSelectionDialog.tsx +++ b/apps/web/src/components/CampaignSelectionDialog.tsx @@ -63,9 +63,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: }); const {data, isLoading} = useSWR( - open - ? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}` - : null, + open ? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}` : null, {revalidateOnFocus: false}, ); @@ -408,11 +406,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}: - diff --git a/apps/web/src/components/EmailEditor/EmailEditor.tsx b/apps/web/src/components/EmailEditor/EmailEditor.tsx index b445a89..afc76ec 100644 --- a/apps/web/src/components/EmailEditor/EmailEditor.tsx +++ b/apps/web/src/components/EmailEditor/EmailEditor.tsx @@ -27,7 +27,7 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue + SelectValue, } from '@plunk/ui'; import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react'; import {network} from '../../lib/network'; @@ -298,7 +298,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`, data: contact.data || {}, - ...(contact.data as Record | null || {}), + ...((contact.data as Record | null) || {}), }; return replaceVariables(currentHtml, contactData); @@ -317,7 +317,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`, data: contact.data || {}, - ...(contact.data as Record | null || {}), + ...((contact.data as Record | null) || {}), }; return replaceVariables(subject, contactData); diff --git a/apps/web/src/components/EmailEditor/HtmlEditor.tsx b/apps/web/src/components/EmailEditor/HtmlEditor.tsx index 589a66c..ff797b5 100644 --- a/apps/web/src/components/EmailEditor/HtmlEditor.tsx +++ b/apps/web/src/components/EmailEditor/HtmlEditor.tsx @@ -64,7 +64,7 @@ export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) { theme, EditorView.lineWrapping, // Enable line wrapping for long lines ], - [theme] + [theme], ); return ( diff --git a/apps/web/src/components/EmailEditor/ResizableImage.tsx b/apps/web/src/components/EmailEditor/ResizableImage.tsx index 836e388..1b2f362 100644 --- a/apps/web/src/components/EmailEditor/ResizableImage.tsx +++ b/apps/web/src/components/EmailEditor/ResizableImage.tsx @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import {Node, mergeAttributes} from '@tiptap/core'; -import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react'; +import {mergeAttributes, Node} from '@tiptap/core'; +import {NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer} from '@tiptap/react'; import {useEffect, useRef, useState} from 'react'; interface ImageAttrs { diff --git a/apps/web/src/components/EmailEditor/Toolbar.tsx b/apps/web/src/components/EmailEditor/Toolbar.tsx index 27ac88c..c1cf4ba 100644 --- a/apps/web/src/components/EmailEditor/Toolbar.tsx +++ b/apps/web/src/components/EmailEditor/Toolbar.tsx @@ -78,11 +78,14 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage setShowLinkInput(false); }, [editor]); - const setColor = useCallback((color: string) => { - if (!editor) return; - editor.chain().focus().setColor(color).run(); - setSelectedColor(color); - }, [editor]); + const setColor = useCallback( + (color: string) => { + if (!editor) return; + editor.chain().focus().setColor(color).run(); + setSelectedColor(color); + }, + [editor], + ); const applyCustomColor = useCallback(() => { if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) { diff --git a/apps/web/src/components/EmailPreviewModal.tsx b/apps/web/src/components/EmailPreviewModal.tsx index 27029d1..cbcc42b 100644 --- a/apps/web/src/components/EmailPreviewModal.tsx +++ b/apps/web/src/components/EmailPreviewModal.tsx @@ -31,7 +31,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => { const classValue = match[1]; if (!classValue) continue; // Split by whitespace to get individual classes - const classes = classValue.split(/\s+/).filter((c) => c.length > 0); + const classes = classValue.split(/\s+/).filter(c => c.length > 0); // Check if any class is NOT in the allowed list const allowedPrefixes = [ 'prose', @@ -42,7 +42,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => { 'selected', 'resize-handle', ]; - const hasDisallowedClass = classes.some((cls) => !allowedPrefixes.some((prefix) => cls.startsWith(prefix))); + const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix))); if (hasDisallowedClass) { hasCustomClasses = true; break; diff --git a/apps/web/src/components/QuickStart.tsx b/apps/web/src/components/QuickStart.tsx index 7509afa..2268822 100644 --- a/apps/web/src/components/QuickStart.tsx +++ b/apps/web/src/components/QuickStart.tsx @@ -234,10 +234,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {

{step.description}

- diff --git a/apps/web/src/components/SecurityWarningBanner.tsx b/apps/web/src/components/SecurityWarningBanner.tsx index fb2bab8..784b447 100644 --- a/apps/web/src/components/SecurityWarningBanner.tsx +++ b/apps/web/src/components/SecurityWarningBanner.tsx @@ -30,9 +30,7 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) { {title}
-

- Your project has exceeded the following security thresholds: -

+

Your project has exceeded the following security thresholds:

    {issues.map((issue, idx) => (
  • {issue}
  • diff --git a/apps/web/src/components/TemplateSelectionDialog.tsx b/apps/web/src/components/TemplateSelectionDialog.tsx index b9a5f96..2c5dd49 100644 --- a/apps/web/src/components/TemplateSelectionDialog.tsx +++ b/apps/web/src/components/TemplateSelectionDialog.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, Input, - Label + Label, } from '@plunk/ui'; import type {Template} from '@plunk/db'; import {ArrowLeft, FileText, Search} from 'lucide-react'; diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index 1d0bf50..0ed6c54 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -12,7 +12,7 @@ import { ReactFlow, useEdgesState, useNodesState, - useReactFlow + useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type {WorkflowStep} from '@plunk/db'; @@ -29,7 +29,7 @@ import { Timer, Trash2, UserCog, - Webhook + Webhook, } from 'lucide-react'; import {useCallback, useEffect, useMemo, useState} from 'react'; import dagre from 'dagre'; diff --git a/apps/web/src/components/WorkflowVisualizer.tsx b/apps/web/src/components/WorkflowVisualizer.tsx index 2358df5..4ece5af 100644 --- a/apps/web/src/components/WorkflowVisualizer.tsx +++ b/apps/web/src/components/WorkflowVisualizer.tsx @@ -10,7 +10,7 @@ import { Position, ReactFlow, useEdgesState, - useNodesState + useNodesState, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type {WorkflowStep} from '@plunk/db'; diff --git a/apps/web/src/lib/hooks/useAnalytics.ts b/apps/web/src/lib/hooks/useAnalytics.ts index 83d0b25..dc89665 100644 --- a/apps/web/src/lib/hooks/useAnalytics.ts +++ b/apps/web/src/lib/hooks/useAnalytics.ts @@ -47,7 +47,6 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData { const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString(); return {startDate: start, endDate: end}; - }, [days, options.startDate, options.endDate]); /* eslint-enable react-hooks/purity */ diff --git a/apps/web/src/lib/hooks/useContacts.ts b/apps/web/src/lib/hooks/useContacts.ts index 9864442..f206b52 100644 --- a/apps/web/src/lib/hooks/useContacts.ts +++ b/apps/web/src/lib/hooks/useContacts.ts @@ -19,13 +19,10 @@ export function useContacts(options: UseContactsOptions = {}) { params.set('search', search); } - const {data, error, mutate, isLoading} = useSWR>( - `/contacts?${params.toString()}`, - { - revalidateOnFocus: false, - dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds - }, - ); + const {data, error, mutate, isLoading} = useSWR>(`/contacts?${params.toString()}`, { + revalidateOnFocus: false, + dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds + }); return { contacts: data?.data || [], diff --git a/apps/web/src/lib/hooks/useDashboardStats.ts b/apps/web/src/lib/hooks/useDashboardStats.ts index 56737c8..14223d0 100644 --- a/apps/web/src/lib/hooks/useDashboardStats.ts +++ b/apps/web/src/lib/hooks/useDashboardStats.ts @@ -20,13 +20,25 @@ export interface DashboardStats { */ export function useDashboardStats(): DashboardStats { // Fetch activity stats (last 30 days by default) - const {data: activityStats, error: activityError, isLoading: isLoadingActivity} = useSWR('/activity/stats'); + const { + data: activityStats, + error: activityError, + isLoading: isLoadingActivity, + } = useSWR('/activity/stats'); // Fetch contacts (only need the total count) - const {data: contactsData, error: contactsError, isLoading: isLoadingContacts} = useSWR('/contacts?limit=1'); + const { + data: contactsData, + error: contactsError, + isLoading: isLoadingContacts, + } = useSWR('/contacts?limit=1'); // Fetch campaigns (only need the total count) - const {data: campaignsData, error: campaignsError, isLoading: isLoadingCampaigns} = useSWR('/campaigns?page=1&limit=1'); + const { + data: campaignsData, + error: campaignsError, + isLoading: isLoadingCampaigns, + } = useSWR('/campaigns?page=1&limit=1'); // Still loading if ANY of the requests are still in progress const isLoading = isLoadingActivity || isLoadingContacts || isLoadingCampaigns; diff --git a/apps/web/src/lib/hooks/useProjectSetupState.ts b/apps/web/src/lib/hooks/useProjectSetupState.ts index bdf40e2..e8d6c00 100644 --- a/apps/web/src/lib/hooks/useProjectSetupState.ts +++ b/apps/web/src/lib/hooks/useProjectSetupState.ts @@ -17,9 +17,7 @@ export interface SetupStateResponse { * Hook to fetch project setup state for dashboard quick start */ export function useProjectSetupState(projectId: string | undefined) { - const {data, error, isLoading} = useSWR( - projectId ? `/projects/${projectId}/setup-state` : null, - ); + const {data, error, isLoading} = useSWR(projectId ? `/projects/${projectId}/setup-state` : null); return { setupState: data?.data, diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 4351d63..2aebacb 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -19,7 +19,15 @@ dayjs.extend(relativeTime); dayjs.extend(advancedFormat); // Routes that don't require authentication -const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset-password', '/auth/verify-email', '/unsubscribe', '/subscribe', '/manage']; +const PUBLIC_ROUTES = [ + '/auth/login', + '/auth/signup', + '/auth/reset-password', + '/auth/verify-email', + '/unsubscribe', + '/subscribe', + '/manage', +]; // Routes that don't require a project const NO_PROJECT_ROUTES = ['/projects/create']; diff --git a/apps/web/src/pages/activity/index.tsx b/apps/web/src/pages/activity/index.tsx index 3af4ae2..ef41795 100644 --- a/apps/web/src/pages/activity/index.tsx +++ b/apps/web/src/pages/activity/index.tsx @@ -76,93 +76,94 @@ export default function ActivityPage() {
    - {/* Header */} -
    -

    Activity

    -

    - Real-time overview of events, emails, and workflow executions across your project. -

    -
    + {/* Header */} +
    +

    Activity

    +

    + Real-time overview of events, emails, and workflow executions across your project. +

    +
    - {/* Stats Grid */} -
    - {statsCards.map(stat => { - const Icon = stat.icon; - return ( - - -
    - {stat.name} -
    - + {/* Stats Grid */} +
    + {statsCards.map(stat => { + const Icon = stat.icon; + return ( + + +
    + {stat.name} +
    + +
    -
    - {stat.value} - - -

    {stat.description}

    -
    - - ); - })} + {stat.value} + + +

    {stat.description}

    +
    + + ); + })} +
    + + {/* Filters */} + + +
    +
    + +
    +
    + +
    +
    +
    +
    + + {/* Activity Feed */} + + + Recent Activity + + Live feed of all activities happening across your project. Updates automatically as new activities + occur. + + + + + +
    - - {/* Filters */} - - -
    -
    - -
    -
    - -
    -
    -
    -
    - - {/* Activity Feed */} - - - Recent Activity - - Live feed of all activities happening across your project. Updates automatically as new activities occur. - - - - - - -
    - + ); } diff --git a/apps/web/src/pages/analytics/index.tsx b/apps/web/src/pages/analytics/index.tsx index 39423a1..d161b4c 100644 --- a/apps/web/src/pages/analytics/index.tsx +++ b/apps/web/src/pages/analytics/index.tsx @@ -15,7 +15,7 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue + SelectValue, } from '@plunk/ui'; import {DashboardLayout} from '../../components/DashboardLayout'; import {useAnalytics} from '../../lib/hooks/useAnalytics'; @@ -30,7 +30,7 @@ import { Megaphone, MousePointerClick, Send, - Zap + Zap, } from 'lucide-react'; import {NextSeo} from 'next-seo'; import {useMemo, useState} from 'react'; diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx index 27f444c..a5d4f6c 100644 --- a/apps/web/src/pages/auth/login.tsx +++ b/apps/web/src/pages/auth/login.tsx @@ -111,224 +111,224 @@ export default function Login() {
    - - -
    - { - e.preventDefault(); - void form.handleSubmit(onSubmit)(e); - }} - className="p-8" - > -
    -
    -

    Welcome back

    -

    Enter your credentials to access your account

    -
    + + + + { + e.preventDefault(); + void form.handleSubmit(onSubmit)(e); + }} + className="p-8" + > +
    +
    +

    Welcome back

    +

    Enter your credentials to access your account

    +
    - {(oauthConfig.github || oauthConfig.google) && ( - <> -
    - {oauthConfig.google && ( - - )} - {oauthConfig.github && ( - - )} -
    -
    -
    - + {(oauthConfig.github || oauthConfig.google) && ( + <> +
    + {oauthConfig.google && ( + + )} + {oauthConfig.github && ( + + )}
    -
    - Or continue with email +
    +
    + +
    +
    + Or continue with email +
    -
    - - )} - -
    - ( - - Email - - - - - - )} - /> -
    -
    - ( - - Password - - - - - - - )} - /> - -
    - - - {errorMessage && ( - - {errorMessage} - + )} - - - +
    + + + {errorMessage && ( + + {errorMessage} + )} - - + -
    - Don't have an account?{' '} - - Sign up - + + + + +
    + Don't have an account?{' '} + + Sign up + +
    -
    - - - - -
    + + +
    +
    +
    - { - setShowReset(open); - if (!open) { - setResetStatus('idle'); - setResetEmail(''); - setResetError(null); - } - }} - > - - - Reset your password - Enter your email to receive a password reset link. - -
    { - void handleResetPassword(e); - }} - className="flex flex-col gap-3 mt-2" - > - setResetEmail(e.target.value)} - required - /> - -
    - - {resetStatus === 'success' && ( -

    - If an account exists, a reset link has been sent to your email. -

    - )} - {resetStatus === 'error' &&

    {resetError}

    } -
    -
    -
    -
    -
    -
    + { + setShowReset(open); + if (!open) { + setResetStatus('idle'); + setResetEmail(''); + setResetError(null); + } + }} + > + + + Reset your password + Enter your email to receive a password reset link. + +
    { + void handleResetPassword(e); + }} + className="flex flex-col gap-3 mt-2" + > + setResetEmail(e.target.value)} + required + /> + +
    + + {resetStatus === 'success' && ( +

    + If an account exists, a reset link has been sent to your email. +

    + )} + {resetStatus === 'error' &&

    {resetError}

    } +
    +
    +
    +
    +
    +
    ); } diff --git a/apps/web/src/pages/auth/reset-password.tsx b/apps/web/src/pages/auth/reset-password.tsx index c09cb05..e286ce9 100644 --- a/apps/web/src/pages/auth/reset-password.tsx +++ b/apps/web/src/pages/auth/reset-password.tsx @@ -1,6 +1,17 @@ import {zodResolver} from '@hookform/resolvers/zod'; import {AuthenticationSchemas} from '@plunk/shared'; -import {Button, Card, CardContent, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input} from '@plunk/ui'; +import { + Button, + Card, + CardContent, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; @@ -115,12 +126,7 @@ export default function ResetPassword() {
    ) : ( - +
    { diff --git a/apps/web/src/pages/auth/signup.tsx b/apps/web/src/pages/auth/signup.tsx index 1c1a99d..fc9dbb2 100644 --- a/apps/web/src/pages/auth/signup.tsx +++ b/apps/web/src/pages/auth/signup.tsx @@ -78,170 +78,170 @@ export default function Signup() {
    - - - - { - e.preventDefault(); - void form.handleSubmit(onSubmit)(e); - }} - className="p-8" - > -
    -
    -

    Create an account

    -

    Get started with Plunk today

    -
    + + + + { + e.preventDefault(); + void form.handleSubmit(onSubmit)(e); + }} + className="p-8" + > +
    +
    +

    Create an account

    +

    Get started with Plunk today

    +
    - {(oauthConfig.github || oauthConfig.google) && ( - <> -
    - {oauthConfig.google && ( - - )} - {oauthConfig.github && ( - - )} -
    -
    -
    - + {(oauthConfig.github || oauthConfig.google) && ( + <> +
    + {oauthConfig.google && ( + + )} + {oauthConfig.github && ( + + )}
    -
    - Or continue with email +
    +
    + +
    +
    + Or continue with email +
    -
    - - )} - -
    - ( - - Email - - - - - - )} - /> -
    -
    - ( - - Password - - - - - - )} - /> -
    - - - {errorMessage && ( - - {errorMessage} - + )} - - - - + -
    - Already have an account?{' '} - - Login - + + + + +
    + Already have an account?{' '} + + Login + +
    -
    - - - - + + + + +
    -
    ); } diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index 7b8146d..ce809ae 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -26,7 +26,7 @@ import { SelectItemWithDescription, SelectTrigger, SelectValue, - StickySaveBar + StickySaveBar, } from '@plunk/ui'; import type {Campaign, Segment} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; @@ -50,7 +50,7 @@ import { Trash2, TrendingUp, Users, - XCircle + XCircle, } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; @@ -1023,9 +1023,7 @@ export default function CampaignDetailsPage() {

    Sent On

    -

    - {formatFullDateTime(new Date(c.sentAt))} -

    +

    {formatFullDateTime(new Date(c.sentAt))}

    )} diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx index b95d5ed..6493c69 100644 --- a/apps/web/src/pages/campaigns/create.tsx +++ b/apps/web/src/pages/campaigns/create.tsx @@ -12,7 +12,7 @@ import { SelectItemWithDescription, SelectTrigger, SelectValue, - Textarea + Textarea, } from '@plunk/ui'; import type {Segment} from '@plunk/db'; import {CampaignAudienceType} from '@plunk/db'; diff --git a/apps/web/src/pages/contacts/[id].tsx b/apps/web/src/pages/contacts/[id].tsx index bc732da..4e8c4e1 100644 --- a/apps/web/src/pages/contacts/[id].tsx +++ b/apps/web/src/pages/contacts/[id].tsx @@ -7,7 +7,7 @@ import { CardTitle, ConfirmDialog, Input, - Label + Label, } from '@plunk/ui'; import type {Contact} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; diff --git a/apps/web/src/pages/contacts/index.tsx b/apps/web/src/pages/contacts/index.tsx index a6d7c57..5126e70 100644 --- a/apps/web/src/pages/contacts/index.tsx +++ b/apps/web/src/pages/contacts/index.tsx @@ -17,6 +17,7 @@ import { Switch, } from '@plunk/ui'; import type {Contact} from '@plunk/db'; +import type {CursorPaginatedResponse} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {KeyValueEditor} from '../../components/KeyValueEditor'; import {network} from '../../lib/network'; @@ -44,13 +45,6 @@ import useSWR from 'swr'; import {ContactSchemas} from '@plunk/shared'; import dayjs from 'dayjs'; -interface PaginatedContacts { - contacts: Contact[]; - total: number; - cursor?: string; - hasMore: boolean; -} - export default function ContactsPage() { const [cursor, setCursor] = useState(undefined); const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]); @@ -68,7 +62,7 @@ export default function ContactsPage() { const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null); const pageSize = 50; - const {data, mutate, isLoading} = useSWR( + const {data, mutate, isLoading} = useSWR>( `/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`, {revalidateOnFocus: false}, ); @@ -76,9 +70,9 @@ export default function ContactsPage() { // Update contacts when data changes useEffect(() => { if (data) { - setContacts(data.contacts); + setContacts(data.data); if (!cursor) { - setTotalCount(data.total || data.contacts.length); + setTotalCount(data.total || data.data.length); } } }, [data, cursor]); diff --git a/apps/web/src/pages/segments/[id].tsx b/apps/web/src/pages/segments/[id].tsx index faceda9..8270a94 100644 --- a/apps/web/src/pages/segments/[id].tsx +++ b/apps/web/src/pages/segments/[id].tsx @@ -10,6 +10,7 @@ import { Label, } from '@plunk/ui'; import type {Contact, Segment} from '@plunk/db'; +import type {PaginatedResponse} from '@plunk/types'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react'; @@ -23,14 +24,6 @@ import {SegmentSchemas} from '@plunk/shared'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; import dayjs from 'dayjs'; -interface PaginatedContacts { - contacts: Contact[]; - total: number; - page: number; - pageSize: number; - totalPages: number; -} - // Count total filters in a condition (recursive) function countFilters(condition: FilterCondition): number { let count = 0; @@ -49,7 +42,7 @@ export default function SegmentDetailPage() { const {data: segment, mutate, isLoading} = useSWR(id ? `/segments/${id}` : null); const [contactsPage, setContactsPage] = useState(1); - const {data: contactsData, isLoading: isLoadingContacts} = useSWR( + const {data: contactsData, isLoading: isLoadingContacts} = useSWR>( id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null, ); @@ -289,7 +282,7 @@ export default function SegmentDetailPage() {

    Loading contacts...

    - ) : contactsData?.contacts.length === 0 ? ( + ) : contactsData?.data.length === 0 ? (

    No contacts match this segment

    @@ -297,7 +290,7 @@ export default function SegmentDetailPage() { ) : ( <>
    - {contactsData?.contacts.map(contact => ( + {contactsData?.data.map(contact => (
    {contact.subscribed ? ( diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 72f0dfe..e641c78 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -38,16 +38,7 @@ import { } from '@plunk/ui'; import {AnimatePresence, motion} from 'framer-motion'; import {NextSeo} from 'next-seo'; -import { - AlertTriangle, - CreditCard, - Database, - Globe, - Mail, - Settings as SettingsIcon, - Shield, - Users, -} from 'lucide-react'; +import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Shield, Users} from 'lucide-react'; import type {z} from 'zod'; import {useRouter} from 'next/router'; import {DashboardLayout} from '../../components/DashboardLayout'; @@ -480,7 +471,7 @@ export default function Settings() { - {SUPPORTED_LANGUAGES.map((lang) => ( + {SUPPORTED_LANGUAGES.map(lang => (
    {lang.flag} @@ -712,7 +703,10 @@ export default function Settings() {
    -
    diff --git a/apps/web/src/pages/unsubscribe/[id].tsx b/apps/web/src/pages/unsubscribe/[id].tsx index 7efe6e3..a143684 100644 --- a/apps/web/src/pages/unsubscribe/[id].tsx +++ b/apps/web/src/pages/unsubscribe/[id].tsx @@ -178,7 +178,9 @@ export default function Unsubscribe() { -

    {translator.t('pages.unsubscribe.successTitle')}

    +

    + {translator.t('pages.unsubscribe.successTitle')} +

    {translator.t('pages.unsubscribe.successDescription', {email: contact?.email || ''})}

    diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 6b6d443..0f05bc2 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -26,7 +26,7 @@ import { SelectItemWithDescription, SelectTrigger, SelectValue, - Switch + Switch, } from '@plunk/ui'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; @@ -47,7 +47,7 @@ import { Trash2, UserCog, Users, - Webhook + Webhook, } from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 90bed7d..d9cb1f8 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -221,11 +221,7 @@ export default function WorkflowsPage() { size="sm" onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)} > - {workflow.enabled ? ( - - ) : ( - - )} + {workflow.enabled ? : }