types: Abstract inline interfaces to @plunk/types
This commit is contained in:
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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} =
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
@@ -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 || [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'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'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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+1
-1
@@ -10,4 +10,4 @@
|
|||||||
"baseDir": "",
|
"baseDir": "",
|
||||||
"uiLibrary": "radix-ui",
|
"uiLibrary": "radix-ui",
|
||||||
"commands": {}
|
"commands": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export { twMerge as cn } from 'tailwind-merge';
|
export {twMerge as cn} from 'tailwind-merge';
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+18
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 +1,2 @@
|
|||||||
export * from './extended.js';
|
export * from './extended.js';
|
||||||
|
export * from './helpers.js';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user