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, NotAuthenticated,
NotFound, NotFound,
RateLimitError, RateLimitError,
ValidationError ValidationError,
} from '../../exceptions/index.js'; } from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.js'; import {EmailService} from '../../services/EmailService.js';
@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, beforeAll} from 'vitest'; import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
// Note: To run these integration tests, you need to: // Note: To run these integration tests, you need to:
+1 -1
View File
@@ -20,7 +20,7 @@ import {
SMTP_ENABLED, SMTP_ENABLED,
STRIPE_ENABLED, STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED, TRACKING_TOGGLE_ENABLED,
WIKI_URI WIKI_URI,
} from './app/constants.js'; } from './app/constants.js';
import {Actions} from './controllers/Actions.js'; import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js'; import {Activity} from './controllers/Activity.js';
+2 -4
View File
@@ -1,8 +1,6 @@
import {Controller, Middleware, Post} from '@overnightjs/core'; import {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared'; import {ActionSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js'; import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
@@ -52,7 +50,7 @@ export class Actions {
@Middleware([requirePublicKey]) @Middleware([requirePublicKey])
@CatchAsync @CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) { 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 // Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body); const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
@@ -173,7 +171,7 @@ export class Actions {
@Middleware([requireSecretKey]) @Middleware([requireSecretKey])
@CatchAsync @CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) { 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 // Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} = 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 {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import {ActivityType} from '@plunk/types'; import {ActivityType} from '@plunk/types';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService} from '../services/ActivityService.js'; import {ActivityService} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -25,7 +23,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined; const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId as string | undefined; const contactId = req.query.contactId as string | undefined;
@@ -66,7 +64,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) { 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -86,7 +84,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) { 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 minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes); const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
@@ -118,7 +116,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90); 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 {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js'; import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -22,7 +20,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -44,7 +42,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { 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 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -68,7 +66,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -92,7 +90,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) { 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 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 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; 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 type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
@@ -20,7 +19,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) { 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} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body); CampaignSchemas.create.parse(req.body);
@@ -63,7 +62,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) { 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 status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20; const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -96,7 +95,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!); const campaign = await CampaignService.get(auth.projectId, id!);
@@ -115,7 +114,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body; req.body;
@@ -161,7 +160,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!); await CampaignService.delete(auth.projectId, id!);
@@ -180,7 +179,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!); const campaign = await CampaignService.duplicate(auth.projectId, id!);
@@ -200,7 +199,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor; const scheduledFor = req.body?.scheduledFor;
@@ -231,7 +230,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!); const campaign = await CampaignService.cancel(auth.projectId, id!);
@@ -251,7 +250,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!); const stats = await CampaignService.getStats(auth.projectId, id!);
@@ -270,7 +269,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body); 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 type {NextFunction, Request, Response} from 'express';
import multer from 'multer'; import multer from 'multer';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js'; import {QueueService} from '../services/QueueService.js';
@@ -35,7 +33,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined; const cursor = req.query.cursor as string | undefined;
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
@@ -54,7 +52,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
try { try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!); const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
@@ -80,7 +78,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) { 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 field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200); const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
@@ -113,7 +111,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const contactId = req.params.id;
if (!contactId) { if (!contactId) {
@@ -133,7 +131,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { 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; const {email, data, subscribed} = req.body;
if (!email) { if (!email) {
@@ -163,7 +161,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 contactId = req.params.id;
const {email, data, subscribed} = req.body; const {email, data, subscribed} = req.body;
@@ -184,7 +182,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const contactId = req.params.id;
if (!contactId) { if (!contactId) {
@@ -284,7 +282,7 @@ export class Contacts {
@Middleware([requireAuth, upload.single('file')]) @Middleware([requireAuth, upload.single('file')])
@CatchAsync @CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) { public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!req.file) { if (!req.file) {
return res.status(400).json({error: 'CSV file is required'}); return res.status(400).json({error: 'CSV file is required'});
@@ -349,7 +347,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) { 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; const field = req.params.field;
if (!field) { if (!field) {
@@ -376,7 +374,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) { 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; const field = req.params.field;
if (!field) { if (!field) {
@@ -402,7 +400,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { 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; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -437,7 +435,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { 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; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -471,7 +469,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) { 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; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { 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 {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js'; import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js'; import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
import {prisma} from '../database/prisma.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains') @Controller('domains')
@@ -21,7 +19,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { 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); const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
@@ -39,7 +37,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) { 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); const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) { if (!auth.userId) {
@@ -87,7 +85,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id); const domain = await DomainService.id(id);
@@ -115,7 +113,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id); const domain = await DomainService.id(id);
+7 -9
View File
@@ -1,8 +1,6 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -17,7 +15,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) { 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; const {name, contactId, emailId, data} = req.body;
if (!name) { if (!name) {
@@ -37,7 +35,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100; const limit = parseInt(req.query.limit as string) || 100;
@@ -54,7 +52,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) { 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -71,7 +69,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) { 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 contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50; const limit = parseInt(req.query.limit as string) || 50;
@@ -92,7 +90,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) { 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!); const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
@@ -108,7 +106,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.params.eventName;
if (!eventName) { if (!eventName) {
@@ -135,7 +133,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.params.eventName;
if (!eventName) { if (!eventName) {
+6 -7
View File
@@ -4,7 +4,6 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
@@ -20,7 +19,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
@@ -84,7 +83,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
@@ -107,7 +106,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
@@ -131,7 +130,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Validate params // Validate params
@@ -182,7 +181,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { 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; const {id, userId} = req.params;
// Validate params // Validate params
@@ -235,7 +234,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) { 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; const {id, userId} = req.params;
// Validate params // Validate params
+8 -10
View File
@@ -1,7 +1,5 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js'; import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -16,7 +14,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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!); const segments = await SegmentService.list(auth.projectId!);
@@ -31,7 +29,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -51,7 +49,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) { 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 segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -73,7 +71,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { 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; const {name, description, condition, trackMembership} = req.body;
if (!name) { if (!name) {
@@ -102,7 +100,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body; const {name, description, condition, trackMembership} = req.body;
@@ -132,7 +130,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -152,7 +150,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -172,7 +170,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
+7 -9
View File
@@ -1,8 +1,6 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db'; import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js'; import {TemplateService} from '../services/TemplateService.js';
@@ -18,7 +16,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
@@ -37,7 +35,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -57,7 +55,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { 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; const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) { if (!name) {
@@ -101,7 +99,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body; const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
@@ -136,7 +134,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -156,7 +154,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -176,7 +174,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { 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 type {NextFunction, Request, Response} from 'express';
import multer from 'multer'; import multer from 'multer';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js'; import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -36,7 +34,7 @@ export class Uploads {
@Middleware([requireAuth, requireEmailVerified, upload.single('image')]) @Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync @CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) { public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
try { try {
if (!S3Service.isS3Enabled()) { 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 {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.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 {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js'; import {BillingLimitService} from '../services/BillingLimitService.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
@@ -24,7 +23,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) { public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -43,7 +42,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) { public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -58,7 +57,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) { public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -105,7 +104,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body); const data = ProjectSchemas.update.parse(req.body);
@@ -125,7 +124,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project // Verify user has admin/owner access to this project
@@ -165,7 +164,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query; const {currency} = req.query;
@@ -245,7 +244,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -283,7 +282,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -307,7 +306,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -374,7 +373,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -491,7 +490,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -570,7 +569,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -594,7 +593,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -668,7 +667,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) { 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); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { 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 {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js'; import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -18,7 +16,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
@@ -38,7 +36,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.query.eventName as string | undefined;
try { try {
@@ -61,7 +59,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { if (!workflowId) {
@@ -81,7 +79,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { 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; const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) { if (!name) {
@@ -111,7 +109,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body; const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
@@ -139,7 +137,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { if (!workflowId) {
@@ -159,7 +157,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body; const {type, name, position, config, templateId, autoConnect} = req.body;
@@ -191,7 +189,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const stepId = req.params.stepId; const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body; const {name, position, config, templateId} = req.body;
@@ -218,7 +216,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const stepId = req.params.stepId; const stepId = req.params.stepId;
@@ -239,7 +237,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body; const {fromStepId, toStepId, condition, priority} = req.body;
@@ -269,7 +267,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const transitionId = req.params.transitionId; const transitionId = req.params.transitionId;
@@ -290,7 +288,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {contactId, context} = req.body; const {contactId, context} = req.body;
@@ -315,7 +313,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -338,7 +336,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const executionId = req.params.executionId; const executionId = req.params.executionId;
@@ -359,7 +357,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const executionId = req.params.executionId; const executionId = req.params.executionId;
@@ -380,7 +378,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { 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 * @param id Optional resource identifier to include in the message
*/ */
public constructor(resource: string, id?: string) { public constructor(resource: string, id?: string) {
const message = id const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
? `${resource} with ID "${id}" was not found`
: `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes // Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = { const errorCodeMap: Record<string, ErrorCode> = {
@@ -1,6 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'; import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers'; import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService // Mock MeterService
@@ -261,13 +261,13 @@ describe('Email Processor', () => {
from: '[email protected]', from: '[email protected]',
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [ attachments: toPrismaJson([
{ {
filename: 'document.pdf', filename: 'document.pdf',
content: 'base64encodedcontent', content: 'base64encodedcontent',
contentType: 'application/pdf', contentType: 'application/pdf',
}, },
] as unknown as Prisma.InputJsonValue, ]),
}, },
}); });
@@ -306,9 +306,7 @@ describe('Email Processor', () => {
from: '[email protected]', from: '[email protected]',
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [ attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
}, },
include: { include: {
project: true, 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 {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers'; import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => { describe('Scheduled Campaign Processor', () => {
let projectId: string; let projectId: string;
+6 -10
View File
@@ -2,18 +2,14 @@ import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken'; import jsonwebtoken from 'jsonwebtoken';
import type {AuthResponse} from '@plunk/types';
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js'; import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js'; import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
import {MembershipService} from '../services/MembershipService.js'; import {MembershipService} from '../services/MembershipService.js';
import {ProjectService} from '../services/ProjectService.js'; import {ProjectService} from '../services/ProjectService.js';
import {UserService} from '../services/UserService.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 * Middleware to check if this unsubscribe is authenticated on the dashboard
* @param req * @param req
@@ -129,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = { res.locals.auth = {
type: 'apiKey', type: 'apiKey',
projectId: project.id, projectId: project.id,
} as AuthResponse; };
// Check if project is disabled - block write operations // Check if project is disabled - block write operations
if (project.disabled) { if (project.disabled) {
@@ -198,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = { res.locals.auth = {
type: 'apiKey', type: 'apiKey',
projectId: project.id, projectId: project.id,
} as AuthResponse; };
// Check if project is disabled - block write operations // Check if project is disabled - block write operations
if (project.disabled) { if (project.disabled) {
@@ -309,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
type: 'jwt', type: 'jwt',
userId, userId,
projectId, projectId,
} as AuthResponse; };
// Check if project is disabled - block write operations // Check if project is disabled - block write operations
if (project?.disabled) { 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) => { export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (auth.type === 'apiKey') { if (auth.type === 'apiKey') {
return next(); return next();
+1 -1
View File
@@ -1,6 +1,6 @@
import type {Prisma} from '@plunk/db'; import type {Prisma} from '@plunk/db';
import {ActivityType} from '@plunk/types';
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types'; import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
import {ActivityType} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
+16 -3
View File
@@ -58,7 +58,11 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate; const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
@@ -191,7 +195,11 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
@@ -295,7 +303,12 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
+1 -1
View File
@@ -1,5 +1,5 @@
import {EmailSourceType} from '@plunk/db'; 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 {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react'; import React from 'react';
import signale from 'signale'; import signale from 'signale';
+6 -5
View File
@@ -1,6 +1,7 @@
import type {Campaign, Contact, Prisma} from '@plunk/db'; import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -59,7 +60,7 @@ export class CampaignService {
fromName: data.fromName, fromName: data.fromName,
replyTo: data.replyTo, replyTo: data.replyTo,
audienceType: data.audienceType, audienceType: data.audienceType,
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue, audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId, segmentId: data.segmentId,
status: CampaignStatus.DRAFT, status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below totalRecipients: 0, // Will be updated below
@@ -107,7 +108,7 @@ export class CampaignService {
if (data.audienceCondition) { if (data.audienceCondition) {
SegmentService.validateCondition(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) { if (data.segmentId !== undefined) {
@@ -721,7 +722,7 @@ export class CampaignService {
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: { case CampaignAudienceType.FILTERED: {
const condition = campaign.audienceCondition as unknown as FilterCondition; const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
if (!condition) { if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); 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'); 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); const segmentWhere = SegmentService.buildConditionClause(condition);
return { return {
+53 -64
View File
@@ -1,6 +1,7 @@
import {type Contact, Prisma} from '@plunk/db'; import {type Contact, Prisma} from '@plunk/db';
import {isValidLanguageCode} from '@plunk/shared'; 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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -229,10 +230,7 @@ export class ContactService {
if (key === 'locale') { if (key === 'locale') {
if (typeof value === 'string') { if (typeof value === 'string') {
if (!isValidLanguageCode(value)) { if (!isValidLanguageCode(value)) {
throw new HttpException( throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
400,
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
);
} }
} else if (value !== null && value !== undefined) { } else if (value !== null && value !== undefined) {
throw new HttpException(400, 'Locale must be a string'); throw new HttpException(400, 'Locale must be a string');
@@ -265,7 +263,7 @@ export class ContactService {
const updated = await prisma.contact.update({ const updated = await prisma.contact.update({
where: {id: existing.id}, where: {id: existing.id},
data: { 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} : {}), ...(subscribed !== undefined ? {subscribed} : {}),
}, },
}); });
@@ -285,7 +283,7 @@ export class ContactService {
data: { data: {
projectId, projectId,
email, 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, subscribed: subscribed ?? true,
}, },
}); });
@@ -671,59 +669,11 @@ export class ContactService {
return {deletedFrom: result}; 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 * Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches * Updates multiple contacts to subscribed=true in batches
*/ */
public static async bulkSubscribe( public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
// Verify all contacts belong to this project // Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: { where: {
@@ -772,10 +722,7 @@ export class ContactService {
/** /**
* Bulk unsubscribe contacts * Bulk unsubscribe contacts
*/ */
public static async bulkUnsubscribe( public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: { where: {
id: {in: contactIds}, id: {in: contactIds},
@@ -822,10 +769,7 @@ export class ContactService {
/** /**
* Bulk delete contacts * Bulk delete contacts
*/ */
public static async bulkDelete( public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
projectId: string,
contactIds: string[],
): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({ const result = await prisma.contact.deleteMany({
where: { where: {
id: {in: contactIds}, id: {in: contactIds},
@@ -836,6 +780,51 @@ export class ContactService {
return {deleted: result.count}; 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 * Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling * 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 type {Contact, Email, Prisma, Project} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.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 {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js'; import {DomainService} from './DomainService.js';
@@ -91,8 +92,8 @@ export class EmailService {
fromName: params.fromName, fromName: params.fromName,
toName: params.toName, toName: params.toName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
templateId: params.templateId, templateId: params.templateId,
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
@@ -152,8 +153,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
campaignId: params.campaignId, campaignId: params.campaignId,
@@ -213,8 +214,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId, workflowExecutionId: params.workflowExecutionId,
@@ -250,8 +251,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId, workflowExecutionId: params.workflowExecutionId,
@@ -509,7 +510,7 @@ export class EmailService {
contactId: email.contactId, contactId: email.contactId,
emailId: email.id, emailId: email.id,
name: `email.${eventType}`, 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 { export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null; 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 * Verify an email address
* - Checks if domain exists (DNS A/AAAA records) * - Checks if domain exists (DNS A/AAAA records)
@@ -146,4 +93,57 @@ export class EmailVerificationService {
return result; 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 type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db'; import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types'; import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -32,7 +33,7 @@ export class EventService {
contactId, contactId,
emailId, emailId,
name: eventName, name: eventName,
data: data ? (data as Prisma.InputJsonValue) : undefined, data: data ? toPrismaJson(data) : undefined,
}, },
}); });
@@ -472,7 +473,7 @@ export class EventService {
contactId, contactId,
status: 'RUNNING', status: 'RUNNING',
currentStepId: triggerStep.id, 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 {Membership} from '@plunk/db';
import type {MemberWithEmail, OwnerInfo, DisabledProjectInfo} from '@plunk/types'; import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.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, userId: m.userId,
email: m.user.email, email: m.user.email,
role: m.role, role: m.role,
@@ -193,11 +193,7 @@ export class MembershipService {
* Add a member to a project * Add a member to a project
* Invalidates cache for the project * Invalidates cache for the project
*/ */
public static async addMember( public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
projectId: string,
userId: string,
role: 'ADMIN' | 'MEMBER',
): Promise<Membership> {
// Check if membership already exists // Check if membership already exists
const existingMembership = await prisma.membership.findUnique({ const existingMembership = await prisma.membership.findUnique({
where: { where: {
@@ -232,11 +228,7 @@ export class MembershipService {
* Throws if trying to change OWNER role * Throws if trying to change OWNER role
* Invalidates cache * Invalidates cache
*/ */
public static async updateRole( public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
projectId: string,
userId: string,
newRole: 'ADMIN' | 'MEMBER',
): Promise<Membership> {
// Get existing membership // Get existing membership
const existingMembership = await prisma.membership.findUnique({ const existingMembership = await prisma.membership.findUnique({
where: { where: {
@@ -341,7 +333,7 @@ export class MembershipService {
return { return {
hasDisabledProject: disabledMemberships.length > 0, 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'; import signale from 'signale';
/** /**
+8 -8
View File
@@ -2,15 +2,15 @@ import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis'; import type {RedisOptions} from 'ioredis';
import signale from 'signale'; import signale from 'signale';
import type { import type {
SendEmailJobData,
CampaignBatchJobData,
ScheduledCampaignJobData,
WorkflowStepJobData,
ContactImportJobData,
BulkContactActionJobData,
SegmentCountJobData,
DomainVerificationJobData,
ApiRequestCleanupJobData, ApiRequestCleanupJobData,
BulkContactActionJobData,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
ScheduledCampaignJobData,
SegmentCountJobData,
SendEmailJobData,
WorkflowStepJobData,
} from '@plunk/types'; } from '@plunk/types';
import {REDIS_URL} from '../app/constants.js'; import {REDIS_URL} from '../app/constants.js';
+5 -5
View File
@@ -1,21 +1,21 @@
import { import {
S3Client,
PutObjectCommand,
CreateBucketCommand, CreateBucketCommand,
HeadBucketCommand, HeadBucketCommand,
PutBucketPolicyCommand, PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import crypto from 'crypto'; import crypto from 'crypto';
import signale from 'signale'; import signale from 'signale';
import { import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID, S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET, S3_ACCESS_KEY_SECRET,
S3_BUCKET, S3_BUCKET,
S3_PUBLIC_URL,
S3_FORCE_PATH_STYLE,
S3_ENABLED, S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PUBLIC_URL,
} from '../app/constants.js'; } from '../app/constants.js';
/** /**
+21 -20
View File
@@ -1,5 +1,6 @@
import {type Contact, Prisma, type Segment} from '@plunk/db'; 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -66,7 +67,7 @@ export class SegmentService {
pageSize = 20, pageSize = 20,
): Promise<PaginatedResponse<Contact>> { ): Promise<PaginatedResponse<Contact>> {
const segment = await this.get(projectId, segmentId); 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 where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
@@ -114,7 +115,7 @@ export class SegmentService {
projectId, projectId,
name: data.name, name: data.name,
description: data.description, description: data.description,
condition: data.condition as unknown as Prisma.InputJsonValue, condition: toPrismaJson(data.condition),
trackMembership: data.trackMembership ?? false, trackMembership: data.trackMembership ?? false,
memberCount, memberCount,
}, },
@@ -161,7 +162,7 @@ export class SegmentService {
updateData.description = data.description; updateData.description = data.description;
} }
if (data.condition !== undefined) { if (data.condition !== undefined) {
updateData.condition = data.condition as unknown as Prisma.InputJsonValue; updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes // Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition); const where = this.buildWhereClause(projectId, data.condition);
@@ -229,7 +230,7 @@ export class SegmentService {
*/ */
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> { public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId); 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 where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
@@ -260,7 +261,7 @@ export class SegmentService {
await Promise.all( await Promise.all(
batch.map(async segment => { batch.map(async segment => {
try { try {
const condition = segment.condition as unknown as FilterCondition; const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); 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'); 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); const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues // 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) * 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) * Build Prisma clause from filter group (recursive)
*/ */
@@ -1,14 +1,15 @@
import type { import type {
Contact, Contact,
Prisma, Prisma,
Template,
Workflow,
WorkflowExecution, WorkflowExecution,
WorkflowStep, WorkflowStep,
WorkflowStepExecution, WorkflowStepExecution,
Template,
Workflow,
} from '@plunk/db'; } from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -80,7 +81,9 @@ export class WorkflowExecutionService {
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`); signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
// This is a delayed step - continue with execution // This is a delayed step - continue with execution
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) { } 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 return; // Already completed or cancelled
} }
@@ -241,7 +244,7 @@ export class WorkflowExecutionService {
data: { data: {
status: StepExecutionStatus.COMPLETED, status: StepExecutionStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
output: result ? (result as Prisma.InputJsonValue) : undefined, output: result ? toPrismaJson(result) : undefined,
}, },
}); });
@@ -440,11 +443,11 @@ export class WorkflowExecutionService {
data: { data: {
status: StepExecutionStatus.COMPLETED, status: StepExecutionStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
output: { output: toPrismaJson({
eventName, eventName,
eventData: data ? (data as Prisma.InputJsonValue) : undefined, eventData: data ? toPrismaJson(data) : undefined,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -866,7 +869,7 @@ export class WorkflowExecutionService {
await prisma.contact.update({ await prisma.contact.update({
where: {id: contact.id}, where: {id: contact.id},
data: { 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 {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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -15,7 +16,12 @@ export class WorkflowService {
/** /**
* Get all workflows for a project with pagination * 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 skip = (page - 1) * pageSize;
const where: Prisma.WorkflowWhereInput = { const where: Prisma.WorkflowWhereInput = {
@@ -299,8 +305,8 @@ export class WorkflowService {
workflowId, workflowId,
type: data.type, type: data.type,
name: data.name, name: data.name,
position: data.position as Prisma.InputJsonValue, position: toPrismaJson(data.position),
config: data.config as Prisma.InputJsonValue, config: toPrismaJson(data.config),
templateId: data.templateId, templateId: data.templateId,
}, },
}); });
@@ -381,8 +387,8 @@ export class WorkflowService {
const updateData: Prisma.WorkflowStepUpdateInput = {}; const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue; if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue; if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
if (data.templateId !== undefined) { if (data.templateId !== undefined) {
if (data.templateId === null) { if (data.templateId === null) {
updateData.template = {disconnect: true}; updateData.template = {disconnect: true};
@@ -574,7 +580,7 @@ export class WorkflowService {
fromStepId: data.fromStepId, fromStepId: data.fromStepId,
condition: { condition: {
path: ['branch'], 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 {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService'; import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService'; import {EmailService} from '../EmailService';
@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, vi} from 'vitest'; import {beforeEach, describe, expect, it, vi} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignService} from '../CampaignService'; import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; 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 {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; 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 p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false}); const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [ await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
p1Contact1.id,
p1Contact2.id,
p2Contact1.id,
p2Contact2.id,
]);
const p1ContactsAfter = await prisma.contact.findMany({ const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id}, 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 {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js'; import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js'; import {HttpException} from '../../exceptions/index.js';
@@ -110,9 +110,7 @@ describe('DomainService', () => {
it('should throw error for invalid email format', async () => { it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i, /invalid email format/i,
@@ -122,13 +120,13 @@ describe('DomainService', () => {
it('should throw error when domain is not registered', async () => { it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), HttpException,
).rejects.toThrow(HttpException); );
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not registered/i,
).rejects.toThrow(/not registered/i); );
}); });
it('should throw error when domain belongs to different project', async () => { it('should throw error when domain belongs to different project', async () => {
@@ -141,13 +139,11 @@ describe('DomainService', () => {
data: {verified: true}, data: {verified: true},
}); });
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(HttpException);
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project2.id), /belongs to a different project/i,
).rejects.toThrow(/belongs to a different project/i); );
}); });
it('should throw error when domain is not verified', async () => { it('should throw error when domain is not verified', async () => {
@@ -155,13 +151,11 @@ describe('DomainService', () => {
await DomainService.addDomain(project.id, 'unverified.com'); await DomainService.addDomain(project.id, 'unverified.com');
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not verified/i,
).rejects.toThrow(/not verified/i); );
}); });
it('should return domain when all checks pass', async () => { it('should return domain when all checks pass', async () => {
@@ -358,9 +352,9 @@ describe('DomainService', () => {
}); });
it('should throw error for non-existent domain', async () => { it('should throw error for non-existent domain', async () => {
await expect( await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'), /domain not found/i,
).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(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
/used in.*template/i,
);
}); });
it('should throw error when domain is used in active campaigns', async () => { 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(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
/used in.*campaign/i,
);
}); });
it('should allow removal when campaign is SENT (completed)', async () => { 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 () => { it('should throw error for non-existent domain', async () => {
await expect( await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'), /domain not found/i,
).rejects.toThrow(/domain not found/i); );
}); });
it('should check usage in multiple templates', async () => { it('should check usage in multiple templates', async () => {
@@ -492,17 +482,15 @@ describe('DomainService', () => {
expect(result.domain).toBe('mail.example.com'); expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail // Different subdomain should fail
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not registered/i,
).rejects.toThrow(/not registered/i); );
}); });
it('should handle email with no @ sign', async () => { it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
/invalid email format/i,
);
}); });
it('should handle email with multiple @ signs', async () => { it('should handle email with multiple @ signs', async () => {
@@ -516,9 +504,7 @@ describe('DomainService', () => {
it('should handle empty email string', async () => { it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
/invalid email format/i,
);
}); });
}); });
@@ -537,11 +523,7 @@ describe('DomainService', () => {
]); ]);
expect(results).toHaveLength(3); expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([ expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
}); });
it('should handle concurrent ownership checks', async () => { it('should handle concurrent ownership checks', async () => {
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match exact string values in standard fields (case-insensitive)', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match boolean values', async () => { it('should match boolean values', async () => {
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match numeric values as strings in JSON fields', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should exclude boolean false values', async () => { it('should exclude boolean false values', async () => {
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => { 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 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 // notEquals only matches where field exists and has different value
expect(ids).toContain(withDifferentValue.id); expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingField.id); expect(ids).not.toContain(withMatchingField.id);
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match substring in email field (case-insensitive)', async () => { 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 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(match1.id);
expect(ids).toContain(match2.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 () => { 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); 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 () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(gmailUser.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => { 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 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 // notContains only matches where field exists and doesn't contain substring
expect(ids).toContain(withDifferentValue.id); expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingSubstring.id); expect(ids).not.toContain(withMatchingSubstring.id);
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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 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(high.id);
expect(ids).toContain(veryHigh.id); expect(ids).toContain(veryHigh.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should exclude values equal to threshold', async () => { 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); 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 () => { it('should work with negative numbers', async () => {
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should work with decimal values', async () => { it('should work with decimal values', async () => {
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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 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(equal.id);
expect(ids).toContain(greater.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 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(low.id);
expect(ids).toContain(veryLow.id); expect(ids).toContain(veryLow.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should exclude values equal to threshold', async () => { 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); 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 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(equal.id);
expect(ids).toContain(less.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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(positive.id); expect(result.data[0].id).toBe(positive.id);
}); });
it('should handle very large numbers', async () => { it('should handle very large numbers', async () => {
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id); expect(result.data[0].id).toBe(withField.id);
}); });
it('should exclude contacts where field is null', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withValue.id); expect(result.data[0].id).toBe(withValue.id);
}); });
it('should match fields with empty string values', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withEmptyString.id); expect(result.data[0].id).toBe(withEmptyString.id);
}); });
it('should match fields with zero values', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withZero.id); expect(result.data[0].id).toBe(withZero.id);
}); });
it('should match fields with boolean false values', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withFalse.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withoutField.id); expect(result.data[0].id).toBe(withoutField.id);
}); });
it('should match contacts where field is null', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withNull.id); expect(result.data[0].id).toBe(withNull.id);
}); });
it('should exclude fields with empty string values', async () => { 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); 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 () => { 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); 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 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); expect(ids).toContain(recent.id);
}); });
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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); expect(ids).toContain(veryRecent.id);
}); });
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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); expect(ids).toContain(justNow.id);
}); });
}); });
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match contacts with JSON date field within specified hours', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match contacts with JSON date field within specified minutes', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should NOT match contacts with JSON date field outside the time range', async () => { 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); 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 () => { 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); 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 () => { 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); 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 () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should require unit parameter for within operator', async () => { 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 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).toContain(newer.id);
expect(ids).not.toContain(older.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 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(first.id);
expect(ids).toContain(second.id); expect(ids).toContain(second.id);
expect(ids).not.toContain(third.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 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(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.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 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(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.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 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); expect(ids).toContain(match.id);
}); });
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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 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(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.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 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(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id); expect(ids).not.toContain(noMatch.id);
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should combine existence checks with value comparisons', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should not match contacts who have not triggered the event', async () => { 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); 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 () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match contacts with event within time range (hours)', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should match contacts with event within time range (minutes)', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should handle events at exact boundary', async () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
// Should not match because events at exact boundary are excluded // 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 () => { 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); 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 () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should not match contacts who have triggered the event', async () => { 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); 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 () => { 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should combine multiple event filters', async () => { it('should combine multiple event filters', async () => {
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
}); });
}); });
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id); expect(result.data[0].id).toBe(subscribed.id);
}); });
it('should filter contacts by custom data fields', async () => { it('should filter contacts by custom data fields', async () => {
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id); expect(result.data[0].id).toBe(proUser.id);
}); });
it('should filter contacts with multiple conditions', async () => { it('should filter contacts with multiple conditions', async () => {
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id); expect(result.data[0].id).toBe(target.id);
}); });
it('should support notEquals operator', async () => { it('should support notEquals operator', async () => {
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id); expect(result.data[0].id).toBe(pro.id);
}); });
it('should support contains operator for strings', async () => { it('should support contains operator for strings', async () => {
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should support exists operator for custom fields', async () => { it('should support exists operator for custom fields', async () => {
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id); expect(result.data[0].id).toBe(withField.id);
}); });
it('should handle empty segments', async () => { it('should handle empty segments', async () => {
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
expect(result.total).toBe(0); expect(result.total).toBe(0);
}); });
}); });
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2); expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should support pagination', async () => { it('should support pagination', async () => {
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
}); });
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10); 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.total).toBe(25);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10); 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); 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 // Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id); let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
// Update contact to pro plan // Update contact to pro plan
await prisma.contact.update({ await prisma.contact.update({
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
// Should now be in segment // Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id); result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id); expect(result.data[0].id).toBe(contact.id);
}); });
it('should be removed from segment when criteria no longer met', async () => { it('should be removed from segment when criteria no longer met', async () => {
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
// Initially in segment // Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id); let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
// Unsubscribe contact // Unsubscribe contact
await prisma.contact.update({ await prisma.contact.update({
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
// Should no longer be in segment // Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort()); expect(result.data.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)).not.toContain(match.id);
}); });
it('should support case-insensitive equals/contains for email strings', async () => { 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 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(lower.id);
expect(equalsIds).toContain(upper.id); expect(equalsIds).toContain(upper.id);
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
}); });
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id); 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(lower.id);
expect(containsIds).toContain(upper.id); expect(containsIds).toContain(upper.id);
}); });
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id); expect(ids).not.toContain(subscribed.id);
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
}); });
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id); 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).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id); expect(notContainsIds).not.toContain(acme.id);
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
}); });
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id); 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).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id); expect(notEqualsIds).not.toContain(acme.id);
}); });
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
}); });
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id); 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(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false); expect(existsIds.has(withNullCompany.id)).toBe(false);
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
}); });
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id); 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(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true); expect(notExistsIds.has(withNullCompany.id)).toBe(true);
}); });
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
}); });
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id); 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(mid.id);
expect(gtIds).toContain(high.id); expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id); expect(gtIds).not.toContain(low.id);
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
}); });
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id); 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(low.id);
expect(lteIds).toContain(mid.id); expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id); expect(lteIds).not.toContain(high.id);
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
}); });
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id); 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).toContain(newer.id);
expect(gtIds).not.toContain(older.id); expect(gtIds).not.toContain(older.id);
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
}); });
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id); 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(older.id);
expect(lteIds).toContain(newer.id); expect(lteIds).toContain(newer.id);
}); });
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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); 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 {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService'; import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
} }
const page1 = await TemplateService.list(projectId, 1, 10); 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.total).toBe(25);
expect(page1.page).toBe(1); expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10); expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10); const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10); expect(page2.data).toHaveLength(10);
expect(page2.page).toBe(2); expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10); const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5); expect(page3.data).toHaveLength(5);
expect(page3.page).toBe(3); expect(page3.page).toBe(3);
}); });
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome'); const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2); 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 () => { it('should filter templates by search query (description)', async () => {
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'new'); const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2); 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')]), expect.arrayContaining([expect.stringContaining('new')]),
); );
}); });
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING); const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2); 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); const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1); 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 () => { 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); const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1); 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 () => { 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); const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest expect(result.data[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id); expect(result.data[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest expect(result.data[2].id).toBe(template1.id); // Oldest
}); });
it('should only return templates for the specified project', async () => { it('should only return templates for the specified project', async () => {
@@ -1,5 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'; 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 {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Premium Status', name: 'Check Premium Status',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.isPremium', field: 'data.isPremium',
operator: 'equals', operator: 'equals',
value: true, value: true,
}, }),
}, },
}); });
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Premium Path', name: 'Premium Path',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.EXIT,
name: 'Standard Path', name: 'Standard Path',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {reason: 'Standard customer'}, config: toPrismaJson({reason: 'Standard customer'}),
}, },
}); });
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: yesStep.id, toStepId: yesStep.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
priority: 1, priority: 1,
}, },
}); });
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
priority: 2, priority: 2,
}, },
}); });
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Premium', name: 'Check Premium',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.isPremium', field: 'data.isPremium',
operator: 'equals', operator: 'equals',
value: true, value: true,
}, }),
}, },
}); });
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Premium', name: 'Premium',
position: {x: 200, y: -50}, position: {x: 200, y: -50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Standard', name: 'Standard',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: yesStep.id, toStepId: yesStep.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Country', name: 'Check Country',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.CONDITION,
name: 'Check Premium (US)', name: 'Check Premium (US)',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.EXIT,
name: 'US Premium', name: 'US Premium',
position: {x: 300, y: -75}, position: {x: 300, y: -75},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'US Standard', name: 'US Standard',
position: {x: 300, y: -25}, position: {x: 300, y: -25},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Non-US', name: 'Non-US',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition1.id, fromStepId: condition1.id,
toStepId: condition2.id, toStepId: condition2.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition1.id, fromStepId: condition1.id,
toStepId: nonUsExit.id, toStepId: nonUsExit.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition2.id, fromStepId: condition2.id,
toStepId: usPremiumExit.id, toStepId: usPremiumExit.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition2.id, fromStepId: condition2.id,
toStepId: usStandardExit.id, toStepId: usStandardExit.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT, type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Purchase', name: 'Wait for Purchase',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
eventName: 'purchase.completed', eventName: 'purchase.completed',
timeout: 3600, // 1 hour timeout: 3600, // 1 hour
}, }),
}, },
}); });
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Complete', name: 'Complete',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT, type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Event', name: 'Wait for Event',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
eventName: 'user.verified', eventName: 'user.verified',
timeout: 3600, timeout: 3600,
}, }),
}, },
}); });
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY, type: WorkflowStepType.DELAY,
name: 'Wait 1 day', name: 'Wait 1 day',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.CONDITION,
name: 'Check Status', name: 'Check Status',
position: {x: 200, y: 0}, 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, type: WorkflowStepType.EXIT,
name: 'Complete', name: 'Complete',
position: {x: 300, y: 0}, position: {x: 300, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: exit.id, toStepId: exit.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'A/B Split', name: 'A/B Split',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.DELAY,
name: 'Path A Delay', name: 'Path A Delay',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.DELAY,
name: 'Path B Delay', name: 'Path B Delay',
position: {x: 200, y: 50}, 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, type: WorkflowStepType.EXIT,
name: 'Merge Point', name: 'Merge Point',
position: {x: 300, y: 0}, position: {x: 300, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: pathA.id, toStepId: pathA.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: pathB.id, toStepId: pathB.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Bad Condition', name: 'Bad Condition',
position: {x: 100, y: 0}, 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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Missing Field', name: 'Check Missing Field',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.nonExistentField', field: 'data.nonExistentField',
operator: 'equals', operator: 'equals',
value: 'something', value: 'something',
}, }),
}, },
}); });
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Exit', name: 'Exit',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Early Exit', name: 'Early Exit',
position: {x: 100, y: 0}, 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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.TRIGGER, type: WorkflowStepType.TRIGGER,
name: 'Start', name: 'Start',
position: {x: 0, y: 0}, position: {x: 0, y: 0},
config: {}, config: toPrismaJson({}),
}); });
const exitStep = await factories.createWorkflowStep({ const exitStep = await factories.createWorkflowStep({
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'End', name: 'End',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: {}, config: toPrismaJson({}),
}); });
await prisma.workflowTransition.create({ await prisma.workflowTransition.create({
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: contextData as Prisma.InputJsonValue, context: toPrismaJson(contextData),
}, },
}); });
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check if first open', name: 'Check if first open',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.isFirstOpen', field: 'event.isFirstOpen',
operator: 'equals', operator: 'equals',
value: true, // Use boolean, not string value: true, // Use boolean, not string
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'First Open', name: 'First Open',
position: {x: 200, y: 0}, 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, type: WorkflowStepType.EXIT,
name: 'Not First Open', name: 'Not First Open',
position: {x: 200, y: 100}, 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}, data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
}); });
await prisma.workflowTransition.create({ 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({ 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 // Create execution with event data
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome Email', subject: 'Welcome Email',
from: '[email protected]', from: '[email protected]',
isFirstOpen: true, isFirstOpen: true,
openedAt: new Date().toISOString(), openedAt: new Date().toISOString(),
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check subject', name: 'Check subject',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.subject', field: 'event.subject',
operator: 'contains', operator: 'contains',
value: 'Welcome', value: 'Welcome',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {reason: 'matched'} as Prisma.InputJsonValue, config: toPrismaJson({reason: 'matched'}),
}, },
}); });
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: exitStep.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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome to Plunk!', subject: 'Welcome to Plunk!',
from: '[email protected]', from: '[email protected]',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check opens count', name: 'Check opens count',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.opens', field: 'event.opens',
operator: 'greaterThan', operator: 'greaterThan',
value: '3', value: '3',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {reason: 'engaged'} as Prisma.InputJsonValue, config: toPrismaJson({reason: 'engaged'}),
}, },
}); });
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: exitStep.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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Newsletter', subject: 'Newsletter',
opens: 5, opens: 5,
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WEBHOOK, type: WorkflowStepType.WEBHOOK,
name: 'Send Webhook', name: 'Send Webhook',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
url: 'https://webhook.example.com/test', url: 'https://webhook.example.com/test',
method: 'POST', method: 'POST',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome Email', subject: 'Welcome Email',
from: '[email protected]', from: '[email protected]',
messageId: 'msg-123', messageId: 'msg-123',
isFirstOpen: true, isFirstOpen: true,
openedAt: '2024-01-15T10:00:00Z', 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 { import {
WorkflowStepType,
StepExecutionStatus, StepExecutionStatus,
WorkflowExecutionStatus,
TemplateType, TemplateType,
WorkflowExecutionStatus,
WorkflowStepType,
WorkflowTriggerType, WorkflowTriggerType,
} from '@plunk/db'; } from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService'; import {WorkflowExecutionService} from '../WorkflowExecutionService';
@@ -205,7 +205,7 @@ describe('WorkflowService', () => {
const page1 = await WorkflowService.list(projectId, 1, 10); 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.total).toBe(25);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
}); });
@@ -218,7 +218,7 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId, 1, 20, 'welcome'); const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2); 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 () => { it('should include step and execution counts', async () => {
@@ -234,8 +234,8 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId); const result = await WorkflowService.list(projectId);
const found = result.workflows.find(w => w.id === workflow.id) as const found = result.data.find(w => w.id === workflow.id) as
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}}) | ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
| undefined; | undefined;
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
expect(found?._count.executions).toBe(1); expect(found?._count.executions).toBe(1);
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
const fragment = convertToEmailHtml(html); const fragment = convertToEmailHtml(html);
return wrapEmailHtml(fragment); return wrapEmailHtml(fragment);
} }
+2 -5
View File
@@ -18,10 +18,7 @@ interface ErrorResponse {
error: string; error: string;
} }
export default async function handler( export default async function handler(req: NextApiRequest, res: NextApiResponse<VerifyEmailResponse | ErrorResponse>) {
req: NextApiRequest,
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
) {
// Only allow POST requests // Only allow POST requests
if (req.method !== 'POST') { if (req.method !== 'POST') {
return res.status(405).json({error: 'Method not allowed'}); return res.status(405).json({error: 'Method not allowed'});
@@ -49,7 +46,7 @@ export default async function handler(
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${secretKey}`, 'Authorization': `Bearer ${secretKey}`,
}, },
body: JSON.stringify({email}), body: JSON.stringify({email}),
}); });
+30 -17
View File
@@ -1,6 +1,7 @@
# Plunk SMTP Relay Server # Plunk SMTP Relay Server
A production-ready SMTP relay server that accepts emails via SMTP protocol and forwards them to the Plunk API's `/v1/send` endpoint. A production-ready SMTP relay server that accepts emails via SMTP protocol and forwards them to the Plunk API's
`/v1/send` endpoint.
## Features ## Features
@@ -19,6 +20,7 @@ SMTP Client → SMTP Server → Email Parser → API /v1/send → AWS SES
``` ```
The SMTP server acts as a relay: The SMTP server acts as a relay:
1. Accepts SMTP connections with authentication 1. Accepts SMTP connections with authentication
2. Validates sender domains against the database 2. Validates sender domains against the database
3. Parses incoming emails 3. Parses incoming emails
@@ -27,15 +29,15 @@ The SMTP server acts as a relay:
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |-------------------|-------------------------|--------------------------------------------------------------------------------|
| `API_URI` | `http://localhost:3000` | Plunk API base URL | | `API_URI` | `http://localhost:3000` | Plunk API base URL |
| `SMTP_DOMAIN` | *(empty)* | SMTP domain - required when using Traefik acme.json with multiple certificates | | `SMTP_DOMAIN` | *(empty)* | SMTP domain - required when using Traefik acme.json with multiple certificates |
| `PORT_SECURE` | `465` | SMTPS port (implicit TLS) | | `PORT_SECURE` | `465` | SMTPS port (implicit TLS) |
| `PORT_SUBMISSION` | `587` | SMTP submission port (STARTTLS) | | `PORT_SUBMISSION` | `587` | SMTP submission port (STARTTLS) |
| `MAX_RECIPIENTS` | `5` | Maximum recipients per email | | `MAX_RECIPIENTS` | `5` | Maximum recipients per email |
| `CERT_PATH` | `/certs` | Path to certificate files | | `CERT_PATH` | `/certs` | Path to certificate files |
| `ACME_JSON_PATH` | `/certs/acme.json` | Path to Traefik acme.json file | | `ACME_JSON_PATH` | `/certs/acme.json` | Path to Traefik acme.json file |
See `.env.self-host.example` in the repository root for full configuration options. See `.env.self-host.example` in the repository root for full configuration options.
@@ -57,7 +59,8 @@ yarn install
yarn workspace smtp dev yarn workspace smtp dev
``` ```
The SMTP server will start on ports 465 and 587. For local testing without TLS certificates, it will run in plaintext mode on port 587. The SMTP server will start on ports 465 and 587. For local testing without TLS certificates, it will run in plaintext
mode on port 587.
### Testing with Telnet ### Testing with Telnet
@@ -85,6 +88,7 @@ QUIT
### Testing with Mail Clients ### Testing with Mail Clients
Configure your email client with: Configure your email client with:
- **SMTP Server**: `localhost` (or your domain in production) - **SMTP Server**: `localhost` (or your domain in production)
- **Port**: 587 (STARTTLS) or 465 (SSL/TLS) - **Port**: 587 (STARTTLS) or 465 (SSL/TLS)
- **Username**: `plunk` - **Username**: `plunk`
@@ -98,14 +102,14 @@ Configure your email client with:
The SMTP server supports TLS certificates through two methods: The SMTP server supports TLS certificates through two methods:
1. **Traefik acme.json** (recommended for Traefik/Dokploy users) 1. **Traefik acme.json** (recommended for Traefik/Dokploy users)
- Mount your Traefik acme.json file to `/certs/acme.json` - Mount your Traefik acme.json file to `/certs/acme.json`
- Set `SMTP_DOMAIN` environment variable to select the correct certificate - Set `SMTP_DOMAIN` environment variable to select the correct certificate
- The server will automatically use the certificate for your domain - The server will automatically use the certificate for your domain
2. **PEM Files** (standard certificate files) 2. **PEM Files** (standard certificate files)
- Mount `privkey.pem` and `fullchain.pem` to `/certs/` - Mount `privkey.pem` and `fullchain.pem` to `/certs/`
- These are standard Let's Encrypt/Certbot filenames - These are standard Let's Encrypt/Certbot filenames
- `SMTP_DOMAIN` is optional when using PEM files - `SMTP_DOMAIN` is optional when using PEM files
If no certificates are mounted, the server will run without TLS (not recommended for production). If no certificates are mounted, the server will run without TLS (not recommended for production).
@@ -114,6 +118,7 @@ If no certificates are mounted, the server will run without TLS (not recommended
The SMTP server is included in the main Plunk Docker image: The SMTP server is included in the main Plunk Docker image:
**Option 1: With Traefik acme.json** **Option 1: With Traefik acme.json**
```bash ```bash
docker run -d \ docker run -d \
-p 465:465 \ -p 465:465 \
@@ -128,6 +133,7 @@ docker run -d \
``` ```
**Option 2: With PEM files** **Option 2: With PEM files**
```bash ```bash
docker run -d \ docker run -d \
-p 465:465 \ -p 465:465 \
@@ -142,6 +148,7 @@ docker run -d \
``` ```
**Option 3: Without TLS (Development Only)** **Option 3: Without TLS (Development Only)**
```bash ```bash
docker run -d \ docker run -d \
-p 587:587 \ -p 587:587 \
@@ -206,6 +213,7 @@ Use PM2 or similar process managers to monitor the service in production.
### TLS Certificate Issues ### TLS Certificate Issues
If TLS is not working: If TLS is not working:
1. Verify certificates are mounted correctly: 1. Verify certificates are mounted correctly:
```bash ```bash
docker exec <container> ls -la /certs/ docker exec <container> ls -la /certs/
@@ -221,6 +229,7 @@ If TLS is not working:
### Connection Refused ### Connection Refused
If clients cannot connect: If clients cannot connect:
1. Verify ports 465 and 587 are exposed and not blocked by firewall 1. Verify ports 465 and 587 are exposed and not blocked by firewall
2. Check if the service is running: `pm2 list` 2. Check if the service is running: `pm2 list`
3. Review logs: `pm2 logs smtp` 3. Review logs: `pm2 logs smtp`
@@ -228,6 +237,7 @@ If clients cannot connect:
### Authentication Failures ### Authentication Failures
If authentication fails: If authentication fails:
1. Verify username is exactly `plunk` 1. Verify username is exactly `plunk`
2. Verify password is the project secret, not public key 2. Verify password is the project secret, not public key
3. Check database connectivity 3. Check database connectivity
@@ -235,6 +245,7 @@ If authentication fails:
### Domain Verification Errors ### Domain Verification Errors
If emails are rejected with domain errors: If emails are rejected with domain errors:
1. Verify domain is added to your project 1. Verify domain is added to your project
2. Check domain verification status in Plunk dashboard 2. Check domain verification status in Plunk dashboard
3. Ensure DNS records are properly configured 3. Ensure DNS records are properly configured
@@ -249,6 +260,7 @@ The SMTP server is designed for high-scale email sending:
- **Fast Authentication**: Single database query per connection - **Fast Authentication**: Single database query per connection
For high-volume sending: For high-volume sending:
- Deploy multiple SMTP server instances behind a load balancer - Deploy multiple SMTP server instances behind a load balancer
- Use connection pooling in your SMTP clients - Use connection pooling in your SMTP clients
- Monitor API rate limits and adjust accordingly - Monitor API rate limits and adjust accordingly
@@ -271,6 +283,7 @@ Authorization: Bearer {project_secret}
``` ```
The API response is translated to SMTP status codes: The API response is translated to SMTP status codes:
- `200 OK``250 Message accepted` - `200 OK``250 Message accepted`
- `4xx/5xx``554 Transaction failed` - `4xx/5xx``554 Transaction failed`
+6 -34
View File
@@ -1,39 +1,11 @@
import {Button} from '@plunk/ui'; import {Button} from '@plunk/ui';
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
import {network} from '../lib/network'; import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem'; import {ActivityItem} from './ActivityItem';
import {Loader2} from 'lucide-react'; import {Loader2} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
export enum ActivityType { export interface ActivityFeedProps {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
export interface Activity {
id: string;
type: ActivityType;
timestamp: string;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
interface ActivityFeedProps {
typeFilter?: string; typeFilter?: string;
dateRangeDays?: number; dateRangeDays?: number;
contactId?: string; contactId?: string;
@@ -84,17 +56,17 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
params.set('contactId', contactId); params.set('contactId', contactId);
} }
const result = await network.fetch<PaginatedActivities>('GET', `/activity?${params.toString()}`); const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
if (cursor) { if (cursor) {
// Append to existing activities // Append to existing activities
setActivities(prev => [...prev, ...result.activities]); setActivities(prev => [...prev, ...result.data]);
} else { } else {
// Replace activities // Replace activities
setActivities(result.activities); setActivities(result.data);
} }
setNextCursor(result.nextCursor); setNextCursor(result.cursor);
setHasMore(result.hasMore); setHasMore(result.hasMore);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load activities'); setError(err instanceof Error ? err.message : 'Failed to load activities');
+1 -1
View File
@@ -1,5 +1,5 @@
import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui'; import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from './ActivityFeed'; import type {Activity} from '@plunk/types';
import {memo, useState} from 'react'; import {memo, useState} from 'react';
import {EmailPreviewModal} from './EmailPreviewModal'; import {EmailPreviewModal} from './EmailPreviewModal';
import { import {
@@ -1,5 +1,5 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert} from '@plunk/ui'; import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {AlertCircle, TrendingUp, Coins} from 'lucide-react'; import {AlertCircle, Coins, TrendingUp} from 'lucide-react';
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption'; import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
import {useConfig} from '../lib/hooks/useConfig'; import {useConfig} from '../lib/hooks/useConfig';
import {useCallback} from 'react'; import {useCallback} from 'react';
+1 -1
View File
@@ -1,4 +1,4 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle, Alert, Badge, Button} from '@plunk/ui'; import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react'; import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices'; import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
@@ -63,9 +63,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
}); });
const {data, isLoading} = useSWR<PaginatedCampaigns>( const {data, isLoading} = useSWR<PaginatedCampaigns>(
open open ? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}` : null,
? `/campaigns?page=${page}&pageSize=10${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`
: null,
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
@@ -408,11 +406,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<Button variant="outline" onClick={handleBack} className="flex-1"> <Button variant="outline" onClick={handleBack} className="flex-1">
Back Back
</Button> </Button>
<Button <Button onClick={handleConfirm} className="flex-1" disabled={!Object.values(selectedFields).some(v => v)}>
onClick={handleConfirm}
className="flex-1"
disabled={!Object.values(selectedFields).some(v => v)}
>
Create Campaign Create Campaign
</Button> </Button>
</div> </div>
@@ -27,7 +27,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react'; import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
@@ -298,7 +298,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`,
manageUrl: `${window.location.origin}/manage/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`,
data: contact.data || {}, data: contact.data || {},
...(contact.data as Record<string, unknown> | null || {}), ...((contact.data as Record<string, unknown> | null) || {}),
}; };
return replaceVariables(currentHtml, contactData); return replaceVariables(currentHtml, contactData);
@@ -317,7 +317,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`, subscribeUrl: `${window.location.origin}/subscribe/${contact.id}`,
manageUrl: `${window.location.origin}/manage/${contact.id}`, manageUrl: `${window.location.origin}/manage/${contact.id}`,
data: contact.data || {}, data: contact.data || {},
...(contact.data as Record<string, unknown> | null || {}), ...((contact.data as Record<string, unknown> | null) || {}),
}; };
return replaceVariables(subject, contactData); return replaceVariables(subject, contactData);
@@ -64,7 +64,7 @@ export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
theme, theme,
EditorView.lineWrapping, // Enable line wrapping for long lines EditorView.lineWrapping, // Enable line wrapping for long lines
], ],
[theme] [theme],
); );
return ( return (
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import {Node, mergeAttributes} from '@tiptap/core'; import {mergeAttributes, Node} from '@tiptap/core';
import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react'; import {NodeViewWrapper, type ReactNodeViewProps, ReactNodeViewRenderer} from '@tiptap/react';
import {useEffect, useRef, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
interface ImageAttrs { interface ImageAttrs {
@@ -78,11 +78,14 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
setShowLinkInput(false); setShowLinkInput(false);
}, [editor]); }, [editor]);
const setColor = useCallback((color: string) => { const setColor = useCallback(
if (!editor) return; (color: string) => {
editor.chain().focus().setColor(color).run(); if (!editor) return;
setSelectedColor(color); editor.chain().focus().setColor(color).run();
}, [editor]); setSelectedColor(color);
},
[editor],
);
const applyCustomColor = useCallback(() => { const applyCustomColor = useCallback(() => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) { if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
@@ -31,7 +31,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => {
const classValue = match[1]; const classValue = match[1];
if (!classValue) continue; if (!classValue) continue;
// Split by whitespace to get individual classes // Split by whitespace to get individual classes
const classes = classValue.split(/\s+/).filter((c) => c.length > 0); const classes = classValue.split(/\s+/).filter(c => c.length > 0);
// Check if any class is NOT in the allowed list // Check if any class is NOT in the allowed list
const allowedPrefixes = [ const allowedPrefixes = [
'prose', 'prose',
@@ -42,7 +42,7 @@ const detectCustomHtmlPatterns = (html: string): boolean => {
'selected', 'selected',
'resize-handle', 'resize-handle',
]; ];
const hasDisallowedClass = classes.some((cls) => !allowedPrefixes.some((prefix) => cls.startsWith(prefix))); const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
if (hasDisallowedClass) { if (hasDisallowedClass) {
hasCustomClasses = true; hasCustomClasses = true;
break; break;
+1 -4
View File
@@ -234,10 +234,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p> <p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p>
</div> </div>
<Link href={step.link} className="flex-shrink-0"> <Link href={step.link} className="flex-shrink-0">
<Button <Button size="sm" variant={step.isCompleted ? 'outline' : 'default'}>
size="sm"
variant={step.isCompleted ? 'outline' : 'default'}
>
{step.linkText} {step.linkText}
</Button> </Button>
</Link> </Link>
@@ -30,9 +30,7 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
<AlertTitle>{title}</AlertTitle> <AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"> <AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-2 flex-1"> <div className="space-y-2 flex-1">
<p className="text-sm font-medium"> <p className="text-sm font-medium">Your project has exceeded the following security thresholds:</p>
Your project has exceeded the following security thresholds:
</p>
<ul className={`list-disc list-inside space-y-1 text-sm ${messageColor}`}> <ul className={`list-disc list-inside space-y-1 text-sm ${messageColor}`}>
{issues.map((issue, idx) => ( {issues.map((issue, idx) => (
<li key={idx}>{issue}</li> <li key={idx}>{issue}</li>
@@ -13,7 +13,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
Input, Input,
Label Label,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Template} from '@plunk/db'; import type {Template} from '@plunk/db';
import {ArrowLeft, FileText, Search} from 'lucide-react'; import {ArrowLeft, FileText, Search} from 'lucide-react';
+2 -2
View File
@@ -12,7 +12,7 @@ import {
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow useReactFlow,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
@@ -29,7 +29,7 @@ import {
Timer, Timer,
Trash2, Trash2,
UserCog, UserCog,
Webhook Webhook,
} from 'lucide-react'; } from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre'; import dagre from 'dagre';
@@ -10,7 +10,7 @@ import {
Position, Position,
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
useNodesState useNodesState,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
-1
View File
@@ -47,7 +47,6 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString(); const start = options.startDate || new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
return {startDate: start, endDate: end}; return {startDate: start, endDate: end};
}, [days, options.startDate, options.endDate]); }, [days, options.startDate, options.endDate]);
/* eslint-enable react-hooks/purity */ /* eslint-enable react-hooks/purity */
+4 -7
View File
@@ -19,13 +19,10 @@ export function useContacts(options: UseContactsOptions = {}) {
params.set('search', search); params.set('search', search);
} }
const {data, error, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>( const {data, error, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(`/contacts?${params.toString()}`, {
`/contacts?${params.toString()}`, revalidateOnFocus: false,
{ dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
revalidateOnFocus: false, });
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
},
);
return { return {
contacts: data?.data || [], contacts: data?.data || [],
+15 -3
View File
@@ -20,13 +20,25 @@ export interface DashboardStats {
*/ */
export function useDashboardStats(): DashboardStats { export function useDashboardStats(): DashboardStats {
// Fetch activity stats (last 30 days by default) // Fetch activity stats (last 30 days by default)
const {data: activityStats, error: activityError, isLoading: isLoadingActivity} = useSWR<ActivityStats>('/activity/stats'); const {
data: activityStats,
error: activityError,
isLoading: isLoadingActivity,
} = useSWR<ActivityStats>('/activity/stats');
// Fetch contacts (only need the total count) // Fetch contacts (only need the total count)
const {data: contactsData, error: contactsError, isLoading: isLoadingContacts} = useSWR<ContactsResponse>('/contacts?limit=1'); const {
data: contactsData,
error: contactsError,
isLoading: isLoadingContacts,
} = useSWR<ContactsResponse>('/contacts?limit=1');
// Fetch campaigns (only need the total count) // Fetch campaigns (only need the total count)
const {data: campaignsData, error: campaignsError, isLoading: isLoadingCampaigns} = useSWR<CampaignsResponse>('/campaigns?page=1&limit=1'); const {
data: campaignsData,
error: campaignsError,
isLoading: isLoadingCampaigns,
} = useSWR<CampaignsResponse>('/campaigns?page=1&limit=1');
// Still loading if ANY of the requests are still in progress // Still loading if ANY of the requests are still in progress
const isLoading = isLoadingActivity || isLoadingContacts || isLoadingCampaigns; const isLoading = isLoadingActivity || isLoadingContacts || isLoadingCampaigns;
@@ -17,9 +17,7 @@ export interface SetupStateResponse {
* Hook to fetch project setup state for dashboard quick start * Hook to fetch project setup state for dashboard quick start
*/ */
export function useProjectSetupState(projectId: string | undefined) { export function useProjectSetupState(projectId: string | undefined) {
const {data, error, isLoading} = useSWR<SetupStateResponse>( const {data, error, isLoading} = useSWR<SetupStateResponse>(projectId ? `/projects/${projectId}/setup-state` : null);
projectId ? `/projects/${projectId}/setup-state` : null,
);
return { return {
setupState: data?.data, setupState: data?.data,
+9 -1
View File
@@ -19,7 +19,15 @@ dayjs.extend(relativeTime);
dayjs.extend(advancedFormat); dayjs.extend(advancedFormat);
// Routes that don't require authentication // Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset-password', '/auth/verify-email', '/unsubscribe', '/subscribe', '/manage']; const PUBLIC_ROUTES = [
'/auth/login',
'/auth/signup',
'/auth/reset-password',
'/auth/verify-email',
'/unsubscribe',
'/subscribe',
'/manage',
];
// Routes that don't require a project // Routes that don't require a project
const NO_PROJECT_ROUTES = ['/projects/create']; const NO_PROJECT_ROUTES = ['/projects/create'];
+85 -84
View File
@@ -76,93 +76,94 @@ export default function ActivityPage() {
<NextSeo title="Activity" /> <NextSeo title="Activity" />
<DashboardLayout> <DashboardLayout>
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Activity</h1> <h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Activity</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base"> <p className="text-neutral-500 mt-2 text-sm sm:text-base">
Real-time overview of events, emails, and workflow executions across your project. Real-time overview of events, emails, and workflow executions across your project.
</p> </p>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statsCards.map(stat => { {statsCards.map(stat => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<Card key={stat.name}> <Card key={stat.name}>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardDescription>{stat.name}</CardDescription> <CardDescription>{stat.name}</CardDescription>
<div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}> <div className={`h-10 w-10 rounded-lg ${stat.bgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${stat.color}`} /> <Icon className={`h-5 w-5 ${stat.color}`} />
</div>
</div> </div>
</div> <CardTitle className="text-2xl">{stat.value}</CardTitle>
<CardTitle className="text-2xl">{stat.value}</CardTitle> </CardHeader>
</CardHeader> <CardContent>
<CardContent> <p className="text-xs text-neutral-500">{stat.description}</p>
<p className="text-xs text-neutral-500">{stat.description}</p> </CardContent>
</CardContent> </Card>
</Card> );
); })}
})} </div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Activity Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</SelectItem>
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger>
<SelectValue placeholder="Last 30 days" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 24 hours</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Live feed of all activities happening across your project. Updates automatically as new activities
occur.
</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
dateRangeDays={parseInt(dateRange)}
/>
</CardContent>
</Card>
</div> </div>
</DashboardLayout>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger>
<SelectValue placeholder="All Activity Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</SelectItem>
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Select value={dateRange} onValueChange={setDateRange}>
<SelectTrigger>
<SelectValue placeholder="Last 30 days" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Last 24 hours</SelectItem>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Live feed of all activities happening across your project. Updates automatically as new activities occur.
</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed
typeFilter={typeFilter === 'ALL' ? undefined : typeFilter}
dateRangeDays={parseInt(dateRange)}
/>
</CardContent>
</Card>
</div>
</DashboardLayout>
</> </>
); );
} }
+2 -2
View File
@@ -15,7 +15,7 @@ import {
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {useAnalytics} from '../../lib/hooks/useAnalytics'; import {useAnalytics} from '../../lib/hooks/useAnalytics';
@@ -30,7 +30,7 @@ import {
Megaphone, Megaphone,
MousePointerClick, MousePointerClick,
Send, Send,
Zap Zap,
} from 'lucide-react'; } from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {useMemo, useState} from 'react'; import {useMemo, useState} from 'react';
+209 -209
View File
@@ -111,224 +111,224 @@ export default function Login() {
<NextSeo title="Login" /> <NextSeo title="Login" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}> <div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}> <div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={e => { onSubmit={e => {
e.preventDefault(); e.preventDefault();
void form.handleSubmit(onSubmit)(e); void form.handleSubmit(onSubmit)(e);
}} }}
className="p-8" className="p-8"
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1> <h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
<p className="text-neutral-600">Enter your credentials to access your account</p> <p className="text-neutral-600">Enter your credentials to access your account</p>
</div> </div>
{(oauthConfig.github || oauthConfig.google) && ( {(oauthConfig.github || oauthConfig.google) && (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
{oauthConfig.google && ( {oauthConfig.google && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`; window.location.href = `${API_URI}/oauth/google/outbound`;
}} }}
> >
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24"> <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
Continue with Google Continue with Google
</Button> </Button>
)} )}
{oauthConfig.github && ( {oauthConfig.github && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`; window.location.href = `${API_URI}/oauth/github/outbound`;
}} }}
> >
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> <svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
Continue with GitHub Continue with GitHub
</Button> </Button>
)} )}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div> </div>
</div> </>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)} )}
</AnimatePresence>
<motion.div layout> <div className="grid gap-2">
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}> <FormField
{form.formState.isSubmitting ? ( control={form.control}
<> name="email"
<svg render={({field}) => (
className="h-4 w-4 animate-spin" <FormItem>
xmlns="http://www.w3.org/2000/svg" <FormLabel>Email</FormLabel>
fill="none" <FormControl>
viewBox="0 0 24 24" <Input placeholder="[email protected]" {...field} />
> </FormControl>
<circle <FormMessage />
className="opacity-25" </FormItem>
cx="12" )}
cy="12" />
r="10" </div>
stroke="currentColor" <div className="grid gap-2">
strokeWidth="4" <FormField
/> control={form.control}
<path name="password"
className="opacity-75" render={({field}) => (
fill="currentColor" <FormItem>
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" <FormLabel>Password</FormLabel>
/> <FormControl>
</svg> <Input placeholder="password" type={'password'} {...field} />
</> </FormControl>
) : (
'Login' <FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)} )}
</Button> </AnimatePresence>
</motion.div>
<div className="text-center text-sm text-neutral-500"> <motion.div layout>
Don&apos;t have an account?{' '} <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900"> {form.formState.isSubmitting ? (
Sign up <>
</Link> <svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Login'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Don&apos;t have an account?{' '}
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
Sign up
</Link>
</div>
</div> </div>
</div> </form>
</form> </Form>
</Form> </CardContent>
</CardContent> </Card>
</Card> </div>
</div>
<Dialog <Dialog
open={showReset} open={showReset}
onOpenChange={open => { onOpenChange={open => {
setShowReset(open); setShowReset(open);
if (!open) { if (!open) {
setResetStatus('idle'); setResetStatus('idle');
setResetEmail(''); setResetEmail('');
setResetError(null); setResetError(null);
} }
}} }}
> >
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>Reset your password</DialogTitle> <DialogTitle>Reset your password</DialogTitle>
<DialogDescription>Enter your email to receive a password reset link.</DialogDescription> <DialogDescription>Enter your email to receive a password reset link.</DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
onSubmit={e => { onSubmit={e => {
void handleResetPassword(e); void handleResetPassword(e);
}} }}
className="flex flex-col gap-3 mt-2" className="flex flex-col gap-3 mt-2"
> >
<Input <Input
type="email" type="email"
placeholder="Enter your email" placeholder="Enter your email"
value={resetEmail} value={resetEmail}
onChange={e => setResetEmail(e.target.value)} onChange={e => setResetEmail(e.target.value)}
required required
/> />
<DialogFooter> <DialogFooter>
<div className={'w-full space-y-2'}> <div className={'w-full space-y-2'}>
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}> <Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'} {resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
</Button> </Button>
{resetStatus === 'success' && ( {resetStatus === 'success' && (
<p className="text-green-600 text-sm"> <p className="text-green-600 text-sm">
If an account exists, a reset link has been sent to your email. If an account exists, a reset link has been sent to your email.
</p> </p>
)} )}
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>} {resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
</div> </div>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</> </>
); );
} }
+13 -7
View File
@@ -1,6 +1,17 @@
import {zodResolver} from '@hookform/resolvers/zod'; import {zodResolver} from '@hookform/resolvers/zod';
import {AuthenticationSchemas} from '@plunk/shared'; import {AuthenticationSchemas} from '@plunk/shared';
import {Button, Card, CardContent, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input} from '@plunk/ui'; import {
Button,
Card,
CardContent,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion'; import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
@@ -115,12 +126,7 @@ export default function ResetPassword() {
</div> </div>
</motion.div> </motion.div>
) : ( ) : (
<motion.div <motion.div key="form" initial={{opacity: 1}} exit={{opacity: 0}} className="p-8">
key="form"
initial={{opacity: 1}}
exit={{opacity: 0}}
className="p-8"
>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={e => { onSubmit={e => {
+155 -155
View File
@@ -78,170 +78,170 @@ export default function Signup() {
<NextSeo title="Sign Up" /> <NextSeo title="Sign Up" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}> <div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}> <div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={e => { onSubmit={e => {
e.preventDefault(); e.preventDefault();
void form.handleSubmit(onSubmit)(e); void form.handleSubmit(onSubmit)(e);
}} }}
className="p-8" className="p-8"
> >
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1> <h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
<p className="text-neutral-600">Get started with Plunk today</p> <p className="text-neutral-600">Get started with Plunk today</p>
</div> </div>
{(oauthConfig.github || oauthConfig.google) && ( {(oauthConfig.github || oauthConfig.google) && (
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
{oauthConfig.google && ( {oauthConfig.google && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`; window.location.href = `${API_URI}/oauth/google/outbound`;
}} }}
> >
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24"> <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/> />
<path <path
fill="currentColor" fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
Continue with Google Continue with Google
</Button> </Button>
)} )}
{oauthConfig.github && ( {oauthConfig.github && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`; window.location.href = `${API_URI}/oauth/github/outbound`;
}} }}
> >
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> <svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg> </svg>
Continue with GitHub Continue with GitHub
</Button> </Button>
)} )}
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div> </div>
</div> </>
</>
)}
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)} )}
</AnimatePresence>
<motion.div layout> <div className="grid gap-2">
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}> <FormField
{form.formState.isSubmitting ? ( control={form.control}
<> name="email"
<svg render={({field}) => (
className="h-4 w-4 animate-spin" <FormItem>
xmlns="http://www.w3.org/2000/svg" <FormLabel>Email</FormLabel>
fill="none" <FormControl>
viewBox="0 0 24 24" <Input placeholder="[email protected]" {...field} />
> </FormControl>
<circle <FormMessage />
className="opacity-25" </FormItem>
cx="12" )}
cy="12" />
r="10" </div>
stroke="currentColor" <div className="grid gap-2">
strokeWidth="4" <FormField
/> control={form.control}
<path name="password"
className="opacity-75" render={({field}) => (
fill="currentColor" <FormItem>
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" <FormLabel>Password</FormLabel>
/> <FormControl>
</svg> <Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</> </FormControl>
) : ( <FormMessage />
'Sign up' </FormItem>
)}
/>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)} )}
</Button> </AnimatePresence>
</motion.div>
<div className="text-center text-sm text-neutral-500"> <motion.div layout>
Already have an account?{' '} <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900"> {form.formState.isSubmitting ? (
Login <>
</Link> <svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Sign up'
)}
</Button>
</motion.div>
<div className="text-center text-sm text-neutral-500">
Already have an account?{' '}
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Login
</Link>
</div>
</div> </div>
</div> </form>
</form> </Form>
</Form> </CardContent>
</CardContent> </Card>
</Card> </div>
</div> </div>
</div>
</> </>
); );
} }
+3 -5
View File
@@ -26,7 +26,7 @@ import {
SelectItemWithDescription, SelectItemWithDescription,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
StickySaveBar StickySaveBar,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db'; import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
@@ -50,7 +50,7 @@ import {
Trash2, Trash2,
TrendingUp, TrendingUp,
Users, Users,
XCircle XCircle,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
@@ -1023,9 +1023,7 @@ export default function CampaignDetailsPage() {
<p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p> <p className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-2">Sent On</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Send className="h-4 w-4 text-neutral-400" /> <Send className="h-4 w-4 text-neutral-400" />
<p className="text-sm font-medium text-neutral-900"> <p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(c.sentAt))}</p>
{formatFullDateTime(new Date(c.sentAt))}
</p>
</div> </div>
</div> </div>
)} )}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
SelectItemWithDescription, SelectItemWithDescription,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Textarea Textarea,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Segment} from '@plunk/db'; import type {Segment} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType} from '@plunk/db';
+1 -1
View File
@@ -7,7 +7,7 @@ import {
CardTitle, CardTitle,
ConfirmDialog, ConfirmDialog,
Input, Input,
Label Label,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Contact} from '@plunk/db'; import type {Contact} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
+4 -10
View File
@@ -17,6 +17,7 @@ import {
Switch, Switch,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Contact} from '@plunk/db'; import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor'; import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
@@ -44,13 +45,6 @@ import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared'; import {ContactSchemas} from '@plunk/shared';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export default function ContactsPage() { export default function ContactsPage() {
const [cursor, setCursor] = useState<string | undefined>(undefined); const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]); const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([undefined]);
@@ -68,7 +62,7 @@ export default function ContactsPage() {
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null); const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
const pageSize = 50; const pageSize = 50;
const {data, mutate, isLoading} = useSWR<PaginatedContacts>( const {data, mutate, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
`/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`, `/contacts?limit=${pageSize}${cursor ? `&cursor=${cursor}` : ''}${search ? `&search=${search}` : ''}`,
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
@@ -76,9 +70,9 @@ export default function ContactsPage() {
// Update contacts when data changes // Update contacts when data changes
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setContacts(data.contacts); setContacts(data.data);
if (!cursor) { if (!cursor) {
setTotalCount(data.total || data.contacts.length); setTotalCount(data.total || data.data.length);
} }
} }
}, [data, cursor]); }, [data, cursor]);
+4 -11
View File
@@ -10,6 +10,7 @@ import {
Label, Label,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Contact, Segment} from '@plunk/db'; import type {Contact, Segment} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react'; import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
@@ -23,14 +24,6 @@ import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder'; import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
// Count total filters in a condition (recursive) // Count total filters in a condition (recursive)
function countFilters(condition: FilterCondition): number { function countFilters(condition: FilterCondition): number {
let count = 0; let count = 0;
@@ -49,7 +42,7 @@ export default function SegmentDetailPage() {
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null); const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
const [contactsPage, setContactsPage] = useState(1); const [contactsPage, setContactsPage] = useState(1);
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedContacts>( const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null, id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
); );
@@ -289,7 +282,7 @@ export default function SegmentDetailPage() {
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-sm text-neutral-500">Loading contacts...</p> <p className="text-sm text-neutral-500">Loading contacts...</p>
</div> </div>
) : contactsData?.contacts.length === 0 ? ( ) : contactsData?.data.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" /> <Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500">No contacts match this segment</p> <p className="text-neutral-500">No contacts match this segment</p>
@@ -297,7 +290,7 @@ export default function SegmentDetailPage() {
) : ( ) : (
<> <>
<div className="space-y-2"> <div className="space-y-2">
{contactsData?.contacts.map(contact => ( {contactsData?.data.map(contact => (
<div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg"> <div key={contact.id} className="flex items-center justify-between p-3 border rounded-lg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{contact.subscribed ? ( {contact.subscribed ? (
+6 -12
View File
@@ -38,16 +38,7 @@ import {
} from '@plunk/ui'; } from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion'; import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import { import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Shield, Users} from 'lucide-react';
AlertTriangle,
CreditCard,
Database,
Globe,
Mail,
Settings as SettingsIcon,
Shield,
Users,
} from 'lucide-react';
import type {z} from 'zod'; import type {z} from 'zod';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
@@ -480,7 +471,7 @@ export default function Settings() {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => ( {SUPPORTED_LANGUAGES.map(lang => (
<SelectItem key={lang.code} value={lang.code}> <SelectItem key={lang.code} value={lang.code}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>{lang.flag}</span> <span>{lang.flag}</span>
@@ -712,7 +703,10 @@ export default function Settings() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex justify-start"> <div className="flex justify-start">
<Button onClick={() => handleStartSubscription(selectedCurrency)} disabled={isLoadingBilling}> <Button
onClick={() => handleStartSubscription(selectedCurrency)}
disabled={isLoadingBilling}
>
{isLoadingBilling ? 'Loading...' : 'Start Subscription'} {isLoadingBilling ? 'Loading...' : 'Start Subscription'}
</Button> </Button>
</div> </div>
+3 -1
View File
@@ -178,7 +178,9 @@ export default function Unsubscribe() {
<path d="M5 13l4 4L19 7" /> <path d="M5 13l4 4L19 7" />
</svg> </svg>
</motion.div> </motion.div>
<h1 className="text-2xl font-bold text-neutral-900">{translator.t('pages.unsubscribe.successTitle')}</h1> <h1 className="text-2xl font-bold text-neutral-900">
{translator.t('pages.unsubscribe.successTitle')}
</h1>
<p className="text-neutral-500"> <p className="text-neutral-500">
{translator.t('pages.unsubscribe.successDescription', {email: contact?.email || ''})} {translator.t('pages.unsubscribe.successDescription', {email: contact?.email || ''})}
</p> </p>
+2 -2
View File
@@ -26,7 +26,7 @@ import {
SelectItemWithDescription, SelectItemWithDescription,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Switch Switch,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
@@ -47,7 +47,7 @@ import {
Trash2, Trash2,
UserCog, UserCog,
Users, Users,
Webhook Webhook,
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
+1 -5
View File
@@ -221,11 +221,7 @@ export default function WorkflowsPage() {
size="sm" size="sm"
onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)} onClick={() => handleToggleEnabled(workflow.id, workflow.enabled)}
> >
{workflow.enabled ? ( {workflow.enabled ? <PowerOff className="h-4 w-4" /> : <Power className="h-4 w-4" />}
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
</Button> </Button>
<Link href={`/workflows/${workflow.id}`}> <Link href={`/workflows/${workflow.id}`}>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
+1 -4
View File
@@ -4,10 +4,7 @@ import {notFound} from 'next/navigation';
export const revalidate = false; export const revalidate = false;
export async function GET( export async function GET(_req: Request, props: {params: Promise<{slug?: string[]}>}) {
_req: Request,
props: {params: Promise<{slug?: string[]}>},
) {
const params = await props.params; const params = await props.params;
const page = source.getPage(params.slug); const page = source.getPage(params.slug);
if (!page) notFound(); if (!page) notFound();
+13 -42
View File
@@ -1,21 +1,11 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import {useMemo, useState} from 'react';
import { import {Check, ChevronDown, Copy, ExternalLinkIcon, MessageCircleIcon} from 'lucide-react';
Check, import {cn} from '../lib/cn';
ChevronDown, import {useCopyButton} from 'fumadocs-ui/utils/use-copy-button';
Copy, import {buttonVariants} from './ui/button';
ExternalLinkIcon, import {Popover, PopoverContent, PopoverTrigger} from 'fumadocs-ui/components/ui/popover';
MessageCircleIcon, import {cva} from 'class-variance-authority';
} from 'lucide-react';
import { cn } from '../lib/cn';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { buttonVariants } from './ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from 'fumadocs-ui/components/ui/popover';
import { cva } from 'class-variance-authority';
const cache = new Map<string, string>(); const cache = new Map<string, string>();
@@ -37,7 +27,7 @@ export function LLMCopyButton({
try { try {
await navigator.clipboard.write([ await navigator.clipboard.write([
new ClipboardItem({ new ClipboardItem({
'text/plain': fetch(markdownUrl).then(async (res) => { 'text/plain': fetch(markdownUrl).then(async res => {
const content = await res.text(); const content = await res.text();
cache.set(markdownUrl, content); cache.set(markdownUrl, content);
@@ -87,10 +77,7 @@ export function ViewOptions({
githubUrl: string; githubUrl: string;
}) { }) {
const items = useMemo(() => { const items = useMemo(() => {
const fullMarkdownUrl = const fullMarkdownUrl = typeof window !== 'undefined' ? new URL(markdownUrl, window.location.origin) : 'loading';
typeof window !== 'undefined'
? new URL(markdownUrl, window.location.origin)
: 'loading';
const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`; const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`;
return [ return [
@@ -110,13 +97,7 @@ export function ViewOptions({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg width="910" height="934" viewBox="0 0 910 934" fill="none" xmlns="http://www.w3.org/2000/svg">
width="910"
height="934"
viewBox="0 0 910 934"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>Scira AI</title> <title>Scira AI</title>
<path <path
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z" d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
@@ -175,12 +156,7 @@ export function ViewOptions({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>OpenAI</title> <title>OpenAI</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" /> <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg> </svg>
@@ -192,12 +168,7 @@ export function ViewOptions({
q, q,
})}`, })}`,
icon: ( icon: (
<svg <svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Anthropic</title> <title>Anthropic</title>
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" /> <path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
</svg> </svg>
@@ -228,7 +199,7 @@ export function ViewOptions({
<ChevronDown className="size-3.5 text-fd-muted-foreground" /> <ChevronDown className="size-3.5 text-fd-muted-foreground" />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="flex flex-col"> <PopoverContent className="flex flex-col">
{items.map((item) => ( {items.map(item => (
<a <a
key={item.href} key={item.href}
href={item.href} href={item.href}
+4 -5
View File
@@ -1,11 +1,10 @@
import { cva, type VariantProps } from 'class-variance-authority'; import {cva, type VariantProps} from 'class-variance-authority';
const variants = { const variants = {
primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80', primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80',
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground', outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground', ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
secondary: secondary: 'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
} as const; } as const;
export const buttonVariants = cva( export const buttonVariants = cva(
@@ -16,8 +15,8 @@ export const buttonVariants = cva(
// fumadocs use `color` instead of `variant` // fumadocs use `color` instead of `variant`
color: variants, color: variants,
size: { size: {
sm: 'gap-1 px-2 py-1.5 text-xs', 'sm': 'gap-1 px-2 py-1.5 text-xs',
icon: 'p-1.5 [&_svg]:size-5', 'icon': 'p-1.5 [&_svg]:size-5',
'icon-sm': 'p-1.5 [&_svg]:size-4.5', 'icon-sm': 'p-1.5 [&_svg]:size-4.5',
'icon-xs': 'p-1 [&_svg]:size-4', 'icon-xs': 'p-1 [&_svg]:size-4',
}, },
+1 -1
View File
@@ -1 +1 @@
export { twMerge as cn } from 'tailwind-merge'; export {twMerge as cn} from 'tailwind-merge';
+7 -13
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import {fileURLToPath} from 'url';
import { config } from 'dotenv'; import {config} from 'dotenv';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.join(__dirname, '..'); const projectRoot = path.join(__dirname, '..');
@@ -10,7 +10,7 @@ const projectRoot = path.join(__dirname, '..');
// Load environment variables from .env file (optional - for local development) // Load environment variables from .env file (optional - for local development)
const envPath = path.join(projectRoot, '.env'); const envPath = path.join(projectRoot, '.env');
if (fs.existsSync(envPath)) { if (fs.existsSync(envPath)) {
config({ path: envPath }); config({path: envPath});
} }
const templatePath = path.join(projectRoot, 'openapi.json'); const templatePath = path.join(projectRoot, 'openapi.json');
@@ -24,8 +24,8 @@ if (!fs.existsSync(templatePath)) {
try { try {
// In development: Replace URLs with local values // In development: Replace URLs with local values
// In Docker: Just copy the file - URLs will be replaced at container startup // In Docker: Just copy the file - URLs will be replaced at container startup
const isDevelopment = process.env.API_URI?.includes('localhost') || const isDevelopment =
process.env.NEXT_PUBLIC_API_URI?.includes('localhost'); process.env.API_URI?.includes('localhost') || process.env.NEXT_PUBLIC_API_URI?.includes('localhost');
if (isDevelopment) { if (isDevelopment) {
// Local development - replace with localhost URLs // Local development - replace with localhost URLs
@@ -33,14 +33,8 @@ try {
const apiUrl = process.env.API_URI || process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080'; const apiUrl = process.env.API_URI || process.env.NEXT_PUBLIC_API_URI || 'http://localhost:8080';
const description = 'Development server'; const description = 'Development server';
content = content.replace( content = content.replace(/"url":\s*"https:\/\/api\.useplunk\.com"/, `"url": "${apiUrl}"`);
/"url":\s*"https:\/\/api\.useplunk\.com"/, content = content.replace(/"description":\s*"Production server"/, `"description": "${description}"`);
`"url": "${apiUrl}"`
);
content = content.replace(
/"description":\s*"Production server"/,
`"description": "${description}"`
);
fs.writeFileSync(localPath, content, 'utf-8'); fs.writeFileSync(localPath, content, 'utf-8');
console.log(`✓ Generated openapi.local.json with server URL: ${apiUrl}`); console.log(`✓ Generated openapi.local.json with server URL: ${apiUrl}`);
+6 -4
View File
@@ -2,8 +2,10 @@
"name": "@plunk/types", "name": "@plunk/types",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"clean": "rimraf node_modules .turbo" "clean": "rimraf node_modules .turbo dist",
"build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@plunk/typescript-config": "*", "@plunk/typescript-config": "*",
@@ -12,8 +14,8 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"exports": { "exports": {
"./integrations/*": "./src/integrations/*.ts", "./integrations/*": "./dist/integrations/*.js",
"./swyp/*": "./src/swyp/*.ts", "./swyp/*": "./dist/swyp/*.js",
".": "./src/index.ts" ".": "./dist/index.js"
} }
} }
+18
View File
@@ -0,0 +1,18 @@
/**
* Express.js type augmentation for Plunk platform
* Extends Express Response.locals to include typed auth property
*/
import type {AuthResponse} from './index.js';
declare global {
namespace Express {
interface Locals {
/**
* Authentication context for the current request
* Set by auth middleware (requireAuth, requireSecretKey, requirePublicKey)
*/
auth: AuthResponse;
}
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Authentication and authorization types
*/
/**
* Authentication response data attached to Express Response.locals
* Contains authentication context for the current request
*/
export interface AuthResponse {
/** Authentication method used (JWT cookie or API key) */
type: 'jwt' | 'apiKey';
/** User ID (only present for JWT authentication) */
userId?: string;
/** Project ID associated with this request */
projectId: string;
}
/**
* Type guard to check if auth is JWT-based
*/
export function isJwtAuth(auth: AuthResponse): auth is AuthResponse & {userId: string} {
return auth.type === 'jwt' && !!auth.userId;
}
/**
* Type guard to check if auth is API key-based
*/
export function isApiKeyAuth(auth: AuthResponse): auth is AuthResponse & {userId: undefined} {
return auth.type === 'apiKey';
}
+3
View File
@@ -12,6 +12,9 @@ export * from './jobs/index.js';
// API service types // API service types
export * from './api/index.js'; export * from './api/index.js';
// Authentication types
export * from './auth/index.js';
// Notification types // Notification types
export * from './notifications/index.js'; export * from './notifications/index.js';
+78
View File
@@ -0,0 +1,78 @@
/**
* Type-safe utilities for working with Prisma JSON fields
*
* Prisma's JSON types are intentionally loose to support the dynamic nature of JSON.
* These helpers provide a safer interface while acknowledging the runtime limitations.
*/
import {Prisma} from '@plunk/db';
/**
* Safely convert a value to Prisma.InputJsonValue for storing in JSON fields
*
* This helper provides better type safety than direct casting while acknowledging
* that Prisma cannot validate the JSON structure at compile time.
*
* @template T - The type being stored (for documentation purposes)
* @param value - The value to convert to Prisma JSON format
* @returns The value as Prisma.InputJsonValue
*
* @example
* ```typescript
* // Filter condition (complex nested object)
* const condition: FilterCondition = { logic: 'AND', groups: [...] };
* await prisma.segment.create({
* data: {
* condition: toPrismaJson(condition)
* }
* });
*
* // Simple object
* const headers = { 'X-Custom': 'value' };
* await prisma.email.create({
* data: {
* headers: toPrismaJson(headers)
* }
* });
* ```
*/
export function toPrismaJson<T>(value: T | null | undefined): Prisma.InputJsonValue {
// Prisma.InputJsonValue accepts: string | number | boolean | null | JsonObject | JsonArray
// We trust that T is JSON-serializable at runtime (including null)
return value as unknown as Prisma.InputJsonValue;
}
/**
* Safely convert Prisma.JsonValue to a typed value when reading from JSON fields
*
* IMPORTANT: This does NOT perform runtime validation. It's a type-safe way to
* document what type you expect, but the caller must validate if needed.
*
* @template T - The expected type
* @param value - The JSON value from Prisma
* @returns The value as type T
*
* @example
* ```typescript
* const segment = await prisma.segment.findUnique({ where: { id } });
* const condition = fromPrismaJson<FilterCondition>(segment.condition);
* // condition is now typed as FilterCondition (but not validated)
* ```
*/
export function fromPrismaJson<T>(value: Prisma.JsonValue): T {
return value as unknown as T;
}
/**
* Optional version of fromPrismaJson that handles null/undefined
*
* @template T - The expected type
* @param value - The JSON value from Prisma (may be null/undefined)
* @returns The value as type T or undefined
*/
export function fromPrismaJsonOptional<T>(value: Prisma.JsonValue | null | undefined): T | undefined {
if (value === null || value === undefined) {
return undefined;
}
return value as unknown as T;
}
+1
View File
@@ -1 +1,2 @@
export * from './extended.js'; export * from './extended.js';
export * from './helpers.js';
+15 -15
View File
@@ -31,7 +31,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
// Paginate through all contacts using cursor // Paginate through all contacts using cursor
while (true) { while (true) {
const result = await ContactService.list(projectId, pageSize, cursor); const result = await ContactService.list(projectId, pageSize, cursor);
totalFetched += result.contacts.length; totalFetched += result.data.length;
if (!result.hasMore) break; if (!result.hasMore) break;
cursor = result.cursor; cursor = result.cursor;
@@ -50,15 +50,15 @@ describe('Performance: Cursor Pagination at Scale', () => {
const pageSize = 10; const pageSize = 10;
const page1 = await ContactService.list(projectId, pageSize); const page1 = await ContactService.list(projectId, pageSize);
expect(page1.contacts).toHaveLength(10); expect(page1.data).toHaveLength(10);
expect(page1.hasMore).toBe(true); expect(page1.hasMore).toBe(true);
const page2 = await ContactService.list(projectId, pageSize, page1.cursor); const page2 = await ContactService.list(projectId, pageSize, page1.cursor);
expect(page2.contacts).toHaveLength(10); expect(page2.data).toHaveLength(10);
expect(page2.hasMore).toBe(true); expect(page2.hasMore).toBe(true);
const page3 = await ContactService.list(projectId, pageSize, page2.cursor); const page3 = await ContactService.list(projectId, pageSize, page2.cursor);
expect(page3.contacts).toHaveLength(5); expect(page3.data).toHaveLength(5);
expect(page3.hasMore).toBe(false); expect(page3.hasMore).toBe(false);
}); });
@@ -81,7 +81,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
// Paginate through filtered results // Paginate through filtered results
while (true) { while (true) {
const result = await ContactService.list(projectId, pageSize, cursor, 'vip'); const result = await ContactService.list(projectId, pageSize, cursor, 'vip');
totalFetched += result.contacts.length; totalFetched += result.data.length;
if (!result.hasMore) break; if (!result.hasMore) break;
cursor = result.cursor; cursor = result.cursor;
@@ -194,7 +194,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
const result = await SegmentService.getContacts(projectId, segment.id, 1, 20); const result = await SegmentService.getContacts(projectId, segment.id, 1, 20);
const duration = Date.now() - start; const duration = Date.now() - start;
expect(result.contacts.length).toBeLessThanOrEqual(20); expect(result.data.length).toBeLessThanOrEqual(20);
expect(duration).toBeLessThan(200); // < 200ms target expect(duration).toBeLessThan(200); // < 200ms target
}, 30000); }, 30000);
@@ -209,9 +209,9 @@ describe('Performance: Cursor Pagination at Scale', () => {
const page10 = await SegmentService.getContacts(projectId, segment.id, 10, 100); const page10 = await SegmentService.getContacts(projectId, segment.id, 10, 100);
const page50 = await SegmentService.getContacts(projectId, segment.id, 50, 100); const page50 = await SegmentService.getContacts(projectId, segment.id, 50, 100);
expect(page1.contacts.length).toBeLessThanOrEqual(100); expect(page1.data.length).toBeLessThanOrEqual(100);
expect(page10.contacts.length).toBeLessThanOrEqual(100); expect(page10.data.length).toBeLessThanOrEqual(100);
expect(page50.contacts.length).toBeLessThanOrEqual(100); expect(page50.data.length).toBeLessThanOrEqual(100);
}, 45000); }, 45000);
}); });
@@ -248,7 +248,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
it('should handle empty dataset gracefully', async () => { it('should handle empty dataset gracefully', async () => {
const result = await ContactService.list(projectId, 20); const result = await ContactService.list(projectId, 20);
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
expect(result.hasMore).toBe(false); expect(result.hasMore).toBe(false);
expect(result.cursor).toBeUndefined(); expect(result.cursor).toBeUndefined();
expect(result.total).toBe(0); expect(result.total).toBe(0);
@@ -259,7 +259,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
const result = await ContactService.list(projectId, 20); const result = await ContactService.list(projectId, 20);
expect(result.contacts).toHaveLength(5); expect(result.data).toHaveLength(5);
expect(result.hasMore).toBe(false); expect(result.hasMore).toBe(false);
expect(result.cursor).toBeUndefined(); expect(result.cursor).toBeUndefined();
}); });
@@ -269,7 +269,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
const result = await ContactService.list(projectId, 20); const result = await ContactService.list(projectId, 20);
expect(result.contacts).toHaveLength(20); expect(result.data).toHaveLength(20);
expect(result.hasMore).toBe(false); expect(result.hasMore).toBe(false);
}); });
@@ -278,12 +278,12 @@ describe('Performance: Cursor Pagination at Scale', () => {
const page1 = await ContactService.list(projectId, 20); const page1 = await ContactService.list(projectId, 20);
expect(page1.contacts).toHaveLength(20); expect(page1.data).toHaveLength(20);
expect(page1.hasMore).toBe(true); expect(page1.hasMore).toBe(true);
const page2 = await ContactService.list(projectId, 20, page1.cursor); const page2 = await ContactService.list(projectId, 20, page1.cursor);
expect(page2.contacts).toHaveLength(1); expect(page2.data).toHaveLength(1);
expect(page2.hasMore).toBe(false); expect(page2.hasMore).toBe(false);
}); });
@@ -297,7 +297,7 @@ describe('Performance: Cursor Pagination at Scale', () => {
while (true) { while (true) {
const result = await ContactService.list(projectId, 100, cursor); const result = await ContactService.list(projectId, 100, cursor);
totalFetched += result.contacts.length; totalFetched += result.data.length;
if (!result.hasMore) break; if (!result.hasMore) break;
cursor = result.cursor; cursor = result.cursor;
} }