types: Abstract inline interfaces to @plunk/types

This commit is contained in:
Dries Augustyns
2026-01-01 10:52:43 +01:00
parent 38da58e5e9
commit 85c992a9f9
95 changed files with 1370 additions and 1350 deletions
@@ -10,7 +10,7 @@ import {
NotAuthenticated,
NotFound,
RateLimitError,
ValidationError
ValidationError,
} from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.js';
@@ -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:
+1 -1
View File
@@ -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';
+2 -4
View File
@@ -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} =
+4 -6
View File
@@ -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);
+4 -6
View File
@@ -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;
+10 -11
View File
@@ -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);
+13 -15
View File
@@ -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) {
+4 -6
View File
@@ -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);
+7 -9
View File
@@ -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) {
+6 -7
View File
@@ -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
+8 -10
View File
@@ -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) {
+7 -9
View File
@@ -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) {
+1 -3
View File
@@ -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()) {
+14 -15
View File
@@ -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) {
+16 -18
View File
@@ -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) {
+1 -3
View File
@@ -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<string, ErrorCode> = {
@@ -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,
@@ -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;
+6 -10
View File
@@ -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();
+1 -1
View File
@@ -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';
+16 -3
View File
@@ -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);
+1 -1
View File
@@ -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';
+6 -5
View File
@@ -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<FilterCondition>(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<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
+53 -64
View File
@@ -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
+11 -10
View File
@@ -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,
},
});
}
@@ -11,59 +11,6 @@ const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | 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<Set<string>> {
// 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<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
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<Set<string>> {
// 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<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
const disposableDomains = await this.getDisposableDomains();
return disposableDomains.has(domain.toLowerCase());
}
}
+3 -2
View File
@@ -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,
},
});
+6 -14
View File
@@ -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<Membership> {
public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// 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<Membership> {
public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// 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),
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {NtfyPriority, NtfyTag, type NtfyNotification} from '@plunk/types';
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
import signale from 'signale';
/**
+8 -8
View File
@@ -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';
+5 -5
View File
@@ -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';
/**
+21 -20
View File
@@ -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<PaginatedResponse<Contact>> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(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<number> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(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<FilterCondition>(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<FilterCondition>(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)
*/
@@ -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,
},
});
+14 -8
View File
@@ -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<PaginatedResponse<Workflow>> {
public static async list(
projectId: string,
page = 1,
pageSize = 20,
search?: string,
): Promise<PaginatedResponse<Workflow>> {
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),
},
},
});
@@ -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';
@@ -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';
@@ -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},
@@ -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 () => {
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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 () => {
@@ -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,
}),
},
});
@@ -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';
@@ -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);