Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1047590e6 | ||
|
|
e3cf0fe0c1 | ||
|
|
940c8938f1 | ||
|
|
e75e07f73f | ||
|
|
940a4d225b | ||
|
|
7386441e61 | ||
|
|
718251c67c | ||
|
|
b6c5471d27 | ||
|
|
fa22b8220a | ||
|
|
a5c575444b | ||
|
|
4dce71a1fe | ||
|
|
ee00eb3481 | ||
|
|
5d44b1606d | ||
|
|
e732c76490 | ||
|
|
fb02051538 | ||
|
|
6f3853de59 | ||
|
|
8790c45edc | ||
|
|
49824aff93 | ||
|
|
492beb095f | ||
|
|
d11c4af4d1 | ||
|
|
82a7c8490e | ||
|
|
457c829b2d | ||
|
|
da7f3e5718 | ||
|
|
76786b2eae | ||
|
|
3a299cae98 | ||
|
|
dd4737cfe9 | ||
|
|
02416654b6 | ||
|
|
85c992a9f9 | ||
|
|
38da58e5e9 | ||
|
|
519b131792 | ||
|
|
be2eb57369 | ||
|
|
19554e6e8f | ||
|
|
68b7e7c082 | ||
|
|
7648f6e1eb | ||
|
|
289c633189 | ||
|
|
857bb8ac9e |
@@ -27,10 +27,10 @@ WIKI_URI=http://localhost:1000
|
||||
|
||||
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://docs.useplunk.com
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
# This affects how application URIs are auto-generated from domain names
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: yarn workspace @plunk/db db:generate
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Run linter
|
||||
run: yarn lint
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.3.0"
|
||||
".": "0.4.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
|
||||
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
|
||||
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
|
||||
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
|
||||
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
|
||||
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
|
||||
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
|
||||
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
|
||||
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
|
||||
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
|
||||
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
|
||||
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
|
||||
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
|
||||
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
|
||||
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
|
||||
|
||||
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
|
||||
|
||||
|
||||
|
||||
@@ -67,9 +67,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
||||
- HTTP API endpoints for the platform
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
@@ -108,6 +108,7 @@ between groups.
|
||||
- Consistent type imports preferred: `import type { ... }`
|
||||
- Unused vars allowed with `_` prefix
|
||||
- Strict type checking enabled across all packages
|
||||
- Try to avoid inline types in favor of shared types in `@plunk/types`
|
||||
|
||||
### Component Structure
|
||||
|
||||
@@ -156,3 +157,9 @@ Required for builds and deployment (see turbo.json and .env.example):
|
||||
runtime
|
||||
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
|
||||
client-side access
|
||||
|
||||
## Plugins
|
||||
There are two plugins installed for you to use.
|
||||
|
||||
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
||||
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
|
||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
@@ -135,7 +135,7 @@ between groups.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
|
||||
+5
-6
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
|
||||
# Build-time arguments for URL configuration
|
||||
# These are only used during the build process (for wiki OpenAPI generation and static assets)
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://app.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://next.useplunk.com
|
||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -273,8 +273,7 @@ COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/sh
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
|
||||
# @plunk/types exports source TypeScript files directly (no build output)
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
|
||||
|
||||
# Copy Prisma schema (needed for migrations at runtime)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
NotAuthenticated,
|
||||
NotFound,
|
||||
RateLimitError,
|
||||
ValidationError
|
||||
ValidationError,
|
||||
} from '../../exceptions/index.js';
|
||||
import {EmailService} from '../../services/EmailService.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
|
||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Note: To run these integration tests, you need to:
|
||||
|
||||
+88
-10
@@ -20,7 +20,7 @@ import {
|
||||
SMTP_ENABLED,
|
||||
STRIPE_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
WIKI_URI
|
||||
WIKI_URI,
|
||||
} from './app/constants.js';
|
||||
import {Actions} from './controllers/Actions.js';
|
||||
import {Activity} from './controllers/Activity.js';
|
||||
@@ -76,23 +76,73 @@ const server = new (class extends Server {
|
||||
// Log all requests to database for historical tracking and analytics
|
||||
this.app.use(databaseRequestLogger);
|
||||
|
||||
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
|
||||
res.set({'Access-Control-Allow-Origin': '*'});
|
||||
next();
|
||||
});
|
||||
|
||||
// Build allowed origins from environment variables
|
||||
const allowedOrigins =
|
||||
NODE_ENV === 'development'
|
||||
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
||||
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
||||
|
||||
this.app.use(
|
||||
// Public API endpoints that should allow all origins
|
||||
const publicApiPaths = ['/v1', '/v1/track', '/v1/send'];
|
||||
|
||||
// Log CORS configuration on startup
|
||||
signale.info('CORS configuration', {
|
||||
environment: NODE_ENV,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
publicApiPaths,
|
||||
});
|
||||
|
||||
// Apply restrictive CORS to all routes EXCEPT public API endpoints
|
||||
this.app.use((req, res, next) => {
|
||||
// Check if this is a public API endpoint
|
||||
const isPublicApi = publicApiPaths.some(path => req.path === path || req.path.startsWith(path + '/'));
|
||||
|
||||
if (isPublicApi) {
|
||||
// For public API endpoints, allow all origins
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
});
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// For other endpoints, apply restrictive CORS
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (e.g., mobile apps, curl, server-to-server)
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check if origin matches any allowed origin (string or regex)
|
||||
const isAllowed = allowedOrigins.some(allowed => {
|
||||
if (allowed instanceof RegExp) {
|
||||
return allowed.test(origin);
|
||||
}
|
||||
return allowed === origin;
|
||||
});
|
||||
|
||||
if (isAllowed) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Log CORS rejection with helpful information
|
||||
signale.warn('CORS request rejected', {
|
||||
origin,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
hint: 'If using HTTPS, ensure USE_HTTPS=true is set in your environment variables',
|
||||
});
|
||||
// Reject the CORS request by passing false (don't send CORS headers)
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
||||
|
||||
@@ -154,6 +204,34 @@ interface ErrorResponse {
|
||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const requestId = res.locals.requestId as string | undefined;
|
||||
|
||||
// Handle JSON parsing errors (from express.json() middleware)
|
||||
if (error instanceof SyntaxError && 'body' in error) {
|
||||
const statusCode = 400;
|
||||
|
||||
logger.warn(
|
||||
'JSON parsing failed',
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
contentType: req.get('content-type'),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: 'Invalid JSON in request body',
|
||||
statusCode,
|
||||
requestId,
|
||||
suggestion: 'Ensure your request body is valid JSON and Content-Type header is set to "application/json".',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
@@ -52,7 +50,7 @@ export class Actions {
|
||||
@Middleware([requirePublicKey])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||
@@ -173,7 +171,7 @@ export class Actions {
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
||||
@@ -277,6 +275,7 @@ export class Actions {
|
||||
// These are always available for template rendering
|
||||
const dataWithSystemVars = {
|
||||
...mergedData,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ActivityService, ActivityType} from '../services/ActivityService.js';
|
||||
import {ActivityService} from '../services/ActivityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('activity')
|
||||
@@ -24,7 +23,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getActivities(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const contactId = req.query.contactId as string | undefined;
|
||||
@@ -65,7 +64,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -85,7 +84,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
|
||||
|
||||
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
|
||||
@@ -117,7 +116,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {AnalyticsService} from '../services/AnalyticsService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -22,7 +20,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -44,7 +42,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
@@ -68,7 +66,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -92,7 +90,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
||||
import {jwt, parseJwt} from '../middleware/auth.js';
|
||||
import {AuthService} from '../services/AuthService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
@@ -64,6 +65,31 @@ export class Auth {
|
||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||
|
||||
// Verify email is valid and not disposable/plus-addressed
|
||||
const verification = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
if (
|
||||
verification.isDisposable ||
|
||||
verification.isPlusAddressed ||
|
||||
!verification.domainExists ||
|
||||
!verification.hasMxRecords
|
||||
) {
|
||||
// Build list of reasons for notification
|
||||
const reasons: string[] = [];
|
||||
if (verification.isDisposable) reasons.push('disposable email');
|
||||
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
||||
if (!verification.domainExists) reasons.push('domain does not exist');
|
||||
if (!verification.hasMxRecords) reasons.push('no MX records');
|
||||
|
||||
// Send notification about failed signup attempt
|
||||
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
data: 'This email address cannot be used for signup',
|
||||
});
|
||||
}
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
@@ -20,11 +19,10 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
CampaignSchemas.create.parse(req.body);
|
||||
|
||||
// Validate audience-specific fields
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
@@ -63,7 +61,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const status = req.query.status as CampaignStatus | undefined;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
@@ -79,13 +77,7 @@ export class Campaigns {
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
campaigns: result.campaigns,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
totalPages: result.totalPages,
|
||||
});
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +88,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||
@@ -115,7 +107,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
req.body;
|
||||
@@ -161,7 +153,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
await CampaignService.delete(auth.projectId, id!);
|
||||
@@ -180,7 +172,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||
@@ -200,7 +192,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const scheduledFor = req.body?.scheduledFor;
|
||||
|
||||
@@ -231,7 +223,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||
@@ -251,7 +243,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||
@@ -270,7 +262,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {QueueService} from '../services/QueueService.js';
|
||||
@@ -16,7 +14,6 @@ const upload = multer({
|
||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// Only accept CSV files
|
||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
@@ -35,7 +32,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -54,7 +51,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
|
||||
@@ -80,7 +77,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||
|
||||
@@ -113,7 +110,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
@@ -133,7 +130,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
if (!email) {
|
||||
@@ -163,7 +160,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
@@ -184,7 +181,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
@@ -284,7 +281,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, upload.single('file')])
|
||||
@CatchAsync
|
||||
public async importCsv(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({error: 'CSV file is required'});
|
||||
@@ -349,7 +346,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
@@ -376,7 +373,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteField(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
@@ -402,7 +399,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
@@ -437,7 +434,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
@@ -471,7 +468,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
|
||||
@@ -4,11 +4,10 @@ import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('domains')
|
||||
@@ -20,20 +19,11 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
|
||||
@@ -47,7 +37,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -55,19 +45,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
// Check if domain is already linked to another project
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||
@@ -107,7 +85,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
@@ -117,16 +95,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has access to the project this domain belongs to
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: domain.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||
|
||||
const verificationStatus = await DomainService.checkVerification(id);
|
||||
|
||||
@@ -144,7 +113,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
@@ -154,19 +123,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has admin access to the project this domain belongs to
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: domain.projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have permission');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
|
||||
await DomainService.removeDomain(id);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -17,7 +15,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, contactId, emailId, data} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -37,7 +35,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
|
||||
@@ -54,7 +52,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -71,7 +69,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.contactId;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
@@ -92,7 +90,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
|
||||
|
||||
@@ -108,7 +106,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
@@ -135,7 +133,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -19,20 +19,11 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get project with relevant data
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -92,20 +83,11 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Use existing SecurityService
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -124,43 +106,18 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get all members of the project
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {
|
||||
projectId: id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const members = await MembershipService.getMembers(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: members.map(m => ({
|
||||
userId: m.user.id,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
})),
|
||||
data: members,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +130,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Validate params
|
||||
@@ -190,19 +147,7 @@ export class Projects {
|
||||
const {email, role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can add members');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Find user by email
|
||||
const userToAdd = await prisma.user.findUnique({
|
||||
@@ -214,28 +159,8 @@ export class Projects {
|
||||
throw new HttpException(404, 'User with this email does not have an account');
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId: userToAdd.id,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new HttpException(409, 'User is already a member of this project');
|
||||
}
|
||||
|
||||
// Create membership
|
||||
const newMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: userToAdd.id,
|
||||
projectId: id,
|
||||
role,
|
||||
},
|
||||
});
|
||||
// Add member to project
|
||||
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -256,7 +181,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
@@ -276,38 +201,7 @@ export class Projects {
|
||||
const {role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can update member roles');
|
||||
}
|
||||
|
||||
// Get target membership
|
||||
const targetMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new HttpException(404, 'Member not found');
|
||||
}
|
||||
|
||||
// Cannot change OWNER role
|
||||
if (targetMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -319,16 +213,8 @@ export class Projects {
|
||||
throw new HttpException(404, 'User not found');
|
||||
}
|
||||
|
||||
// Update role
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
data: {role},
|
||||
});
|
||||
// Update role (service handles validation)
|
||||
await MembershipService.updateRole(id, userId, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -348,7 +234,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async removeMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
@@ -360,53 +246,15 @@ export class Projects {
|
||||
}
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can remove members');
|
||||
}
|
||||
|
||||
// Get target membership
|
||||
const targetMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new HttpException(404, 'Member not found');
|
||||
}
|
||||
|
||||
// Cannot remove OWNER
|
||||
if (targetMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot remove the project owner');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Cannot remove yourself
|
||||
if (userId === auth.userId) {
|
||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||
}
|
||||
|
||||
// Delete membership
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Remove member (service handles validation)
|
||||
await MembershipService.removeMember(id, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -16,7 +14,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const segments = await SegmentService.list(auth.projectId!);
|
||||
|
||||
@@ -31,7 +29,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -51,7 +49,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContacts(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
@@ -73,7 +71,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -102,7 +100,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
|
||||
@@ -132,7 +130,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -152,7 +150,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async compute(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -172,7 +170,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async refresh(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {TemplateType} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {TemplateService} from '../services/TemplateService.js';
|
||||
@@ -18,7 +16,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -37,7 +35,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -57,7 +55,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -101,7 +99,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
@@ -136,7 +134,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -156,7 +154,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -176,7 +174,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import * as S3Service from '../services/S3Service.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -15,7 +13,6 @@ const upload = multer({
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// Only accept image files
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
@@ -36,7 +33,7 @@ export class Uploads {
|
||||
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
|
||||
@CatchAsync
|
||||
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
if (!S3Service.isS3Enabled()) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
@@ -23,7 +23,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async me(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -42,7 +42,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async meProjects(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -57,7 +57,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -104,24 +104,12 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const data = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to update it');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Update the project
|
||||
const project = await prisma.project.update({
|
||||
@@ -136,23 +124,11 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to regenerate keys');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
@@ -188,7 +164,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {currency} = req.query;
|
||||
|
||||
@@ -197,20 +173,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -280,7 +244,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -288,20 +252,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -330,7 +282,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -342,16 +294,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing limits');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get billing limits and usage
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
@@ -363,7 +306,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -377,19 +320,7 @@ export class Users {
|
||||
const data = BillingLimitSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -442,7 +373,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -459,16 +390,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
@@ -568,7 +490,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -585,16 +507,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -656,7 +569,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -668,16 +581,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view security metrics');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get security metrics
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -689,7 +593,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -701,19 +605,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
@@ -775,7 +667,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -787,21 +679,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has owner or admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['OWNER', 'ADMIN'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound(
|
||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
||||
);
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get project to check for active subscription and disabled status
|
||||
const project = await prisma.project.findUnique({
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {WorkflowService} from '../services/WorkflowService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -18,7 +16,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -38,7 +36,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
|
||||
try {
|
||||
@@ -61,7 +59,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
@@ -81,7 +79,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, eventName, enabled, allowReentry} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -111,7 +109,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
|
||||
|
||||
@@ -139,7 +137,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
@@ -159,7 +157,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {type, name, position, config, templateId, autoConnect} = req.body;
|
||||
|
||||
@@ -191,7 +189,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
const {name, position, config, templateId} = req.body;
|
||||
@@ -218,7 +216,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
|
||||
@@ -239,7 +237,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {fromStepId, toStepId, condition, priority} = req.body;
|
||||
|
||||
@@ -269,7 +267,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const transitionId = req.params.transitionId;
|
||||
|
||||
@@ -290,7 +288,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async startExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {contactId, context} = req.body;
|
||||
|
||||
@@ -315,7 +313,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
@@ -338,7 +336,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
@@ -359,7 +357,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
@@ -380,7 +378,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
|
||||
@@ -74,9 +74,7 @@ export class NotFound extends HttpException {
|
||||
* @param id Optional resource identifier to include in the message
|
||||
*/
|
||||
public constructor(resource: string, id?: string) {
|
||||
const message = id
|
||||
? `${resource} with ID "${id}" was not found`
|
||||
: `That ${resource.toLowerCase()} was not found`;
|
||||
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
|
||||
|
||||
// Map common resources to specific error codes
|
||||
const errorCodeMap: Record<string, ErrorCode> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock MeterService
|
||||
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Mock the email processor logic
|
||||
// In a real implementation, you would:
|
||||
// 1. Create job tester
|
||||
// 2. Mock SES service
|
||||
// 3. Process the job
|
||||
// 4. Verify status changes
|
||||
|
||||
// Simulate processing
|
||||
await prisma.email.update({
|
||||
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: [
|
||||
attachments: toPrismaJson([
|
||||
{
|
||||
filename: 'document.pdf',
|
||||
content: 'base64encodedcontent',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: [
|
||||
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
|
||||
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
describe('Scheduled Campaign Processor', () => {
|
||||
let projectId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {ApiRequestCleanupJobData} from '@plunk/types';
|
||||
import type {Job} from 'bullmq';
|
||||
import {Worker} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
@@ -5,7 +6,6 @@ import signale from 'signale';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
|
||||
|
||||
/**
|
||||
* API Request Cleanup Worker
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* Processes campaign batches (queues emails for each contact in the batch)
|
||||
*/
|
||||
|
||||
import type {CampaignBatchJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||
import {campaignQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createCampaignWorker() {
|
||||
const worker = new Worker<CampaignBatchJobData>(
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Processes domain verification jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {DomainVerificationJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
|
||||
import {domainVerificationQueue} from '../services/QueueService.js';
|
||||
|
||||
import {checkDomainVerifications} from './domain-verification.js';
|
||||
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
* Scheduled to run every 5 minutes via repeatable jobs
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
|
||||
@@ -26,7 +30,15 @@ export async function checkDomainVerifications() {
|
||||
// Process domains in batches of 99 (AWS SES limit is 100)
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const domains = await prisma.domain.findMany({
|
||||
select: {id: true, domain: true, projectId: true, verified: true},
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
projectId: true,
|
||||
verified: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
|
||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
if (
|
||||
error?.Code === 'Throttling' ||
|
||||
error?.name === 'Throttling' ||
|
||||
error?.message?.includes('Throttling')
|
||||
) {
|
||||
signale.warn(
|
||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
signale.error(
|
||||
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +120,31 @@ export async function checkDomainVerifications() {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
|
||||
}
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||
);
|
||||
await redis.setex(cacheKey, 604800, '1'); // 7 days
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
@@ -111,6 +154,36 @@ export async function checkDomainVerifications() {
|
||||
if (dbDomain.verified && !isVerified) {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||
);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
await redis.setex(cacheKey, ttl, '1');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`);
|
||||
}
|
||||
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
*/
|
||||
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||
import {emailQueue} from '../services/QueueService.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Determine the email sending rate limit (emails per second)
|
||||
@@ -203,6 +204,7 @@ export async function createEmailWorker() {
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Processes CSV contact imports with validation and batch processing
|
||||
*/
|
||||
|
||||
import type {ContactImportJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
import signale from 'signale';
|
||||
@@ -10,7 +11,7 @@ import signale from 'signale';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
|
||||
import {importQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import type {ScheduledCampaignJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
|
||||
import {scheduledQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createScheduledCampaignWorker() {
|
||||
const worker = new Worker<ScheduledCampaignJobData>(
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Processes segment count update jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {SegmentCountJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
|
||||
import {segmentCountQueue} from '../services/QueueService.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Processes workflow steps from the queue (for delayed steps)
|
||||
*/
|
||||
|
||||
import type {WorkflowStepJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||
import {workflowQueue} from '../services/QueueService.js';
|
||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||
|
||||
export function createWorkflowWorker() {
|
||||
|
||||
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
|
||||
projectId = project.id;
|
||||
userId = user.id;
|
||||
|
||||
// Mock request object
|
||||
req = {
|
||||
method: 'POST',
|
||||
path: '/v1/send',
|
||||
@@ -334,11 +333,6 @@ describe('Request Logger Middleware', () => {
|
||||
await res.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// TODO: Add assertion to verify request was NOT logged when disabled
|
||||
// const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
// where: {id: 'test-request-id-123'},
|
||||
// });
|
||||
// expect(loggedRequest).toBeNull();
|
||||
|
||||
// Restore original value
|
||||
if (originalEnv !== undefined) {
|
||||
|
||||
+28
-109
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '@plunk/types';
|
||||
|
||||
export interface AuthResponse {
|
||||
type: 'jwt' | 'apiKey';
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
}
|
||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
|
||||
/**
|
||||
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
||||
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require project access
|
||||
* Validates that the user is authenticated and has access to the project specified in X-Project-Id header
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireProjectAccess = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// First authenticate the user
|
||||
const userId = parseJwt(req);
|
||||
|
||||
// Get project ID from header
|
||||
const projectId = req.headers['x-project-id'] as string | undefined;
|
||||
|
||||
if (!projectId) {
|
||||
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Verify user has access to this project and get project status
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require public API key authentication (for /v1/track endpoint only)
|
||||
* Validates that the request has a valid public key and sets the project
|
||||
@@ -174,12 +106,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by public key only
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
public: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.public(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -193,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by secret key only
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
secret: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -262,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
// Look up project by secret key only (public keys not allowed)
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
secret: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -360,18 +292,8 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
|
||||
// Verify user has access to this project and get project status
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
}),
|
||||
MembershipService.getMembership(userId, projectId),
|
||||
ProjectService.id(projectId),
|
||||
]);
|
||||
|
||||
if (!membership) {
|
||||
@@ -383,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
@@ -414,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
*/
|
||||
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (auth.type === 'apiKey') {
|
||||
return next();
|
||||
@@ -424,10 +346,7 @@ export const requireEmailVerified = async (req: Request, res: Response, next: Ne
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {id: auth.userId},
|
||||
select: {emailVerified: true, type: true},
|
||||
});
|
||||
const user = await UserService.id(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
|
||||
@@ -1,61 +1,12 @@
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Activity types that can be tracked
|
||||
*/
|
||||
export enum ActivityType {
|
||||
EVENT_TRIGGERED = 'event.triggered',
|
||||
EMAIL_SENT = 'email.sent',
|
||||
EMAIL_DELIVERED = 'email.delivered',
|
||||
EMAIL_OPENED = 'email.opened',
|
||||
EMAIL_CLICKED = 'email.clicked',
|
||||
EMAIL_BOUNCED = 'email.bounced',
|
||||
CAMPAIGN_SENT = 'campaign.sent',
|
||||
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
||||
WORKFLOW_STARTED = 'workflow.started',
|
||||
WORKFLOW_COMPLETED = 'workflow.completed',
|
||||
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified activity item
|
||||
*/
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
timestamp: Date;
|
||||
contactEmail?: string;
|
||||
contactId?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated activity response
|
||||
*/
|
||||
export interface PaginatedActivities {
|
||||
activities: Activity[];
|
||||
nextCursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity stats for dashboard
|
||||
*/
|
||||
export interface ActivityStats {
|
||||
totalEvents: number;
|
||||
totalEmailsSent: number;
|
||||
totalEmailsOpened: number;
|
||||
totalEmailsClicked: number;
|
||||
totalWorkflowsStarted: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity Service
|
||||
*
|
||||
@@ -107,7 +58,7 @@ export class ActivityService {
|
||||
contactId?: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<PaginatedActivities> {
|
||||
): Promise<CursorPaginatedResponse<Activity>> {
|
||||
// Cap limit to prevent abuse
|
||||
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
||||
|
||||
@@ -158,8 +109,8 @@ export class ActivityService {
|
||||
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
|
||||
|
||||
return {
|
||||
activities: results,
|
||||
nextCursor,
|
||||
data: results,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,14 +58,16 @@ export class AnalyticsService {
|
||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.timeseries(
|
||||
projectId,
|
||||
limitedStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// Raw SQL query for efficient daily aggregation
|
||||
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
|
||||
const result = await prisma.$queryRaw<
|
||||
{
|
||||
date: Date;
|
||||
@@ -191,7 +193,11 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.campaignStats(
|
||||
projectId,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
@@ -295,7 +301,12 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.topEvents(
|
||||
projectId,
|
||||
limit,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
|
||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
@@ -8,41 +9,9 @@ import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
* Usage information for a specific email category
|
||||
*/
|
||||
export interface CategoryUsage {
|
||||
limit: number | null; // null = unlimited
|
||||
usage: number;
|
||||
percentage: number; // 0-100
|
||||
isWarning: boolean; // true if >= 80%
|
||||
isBlocked: boolean; // true if >= 100%
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete billing limits and usage for a project
|
||||
*/
|
||||
export interface BillingLimitsResponse {
|
||||
workflows: CategoryUsage;
|
||||
campaigns: CategoryUsage;
|
||||
transactional: CategoryUsage;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of limit check
|
||||
*/
|
||||
export interface LimitCheckResult {
|
||||
allowed: boolean;
|
||||
warning: boolean; // true if >= 80% but < 100%
|
||||
usage: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing Limit Service
|
||||
* Handles usage tracking and enforcement of billing limits per email category
|
||||
@@ -616,11 +585,8 @@ export class BillingLimitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -669,11 +635,8 @@ export class BillingLimitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -18,32 +19,6 @@ import {sendRawEmail} from './SESService.js';
|
||||
|
||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||
|
||||
export interface CreateCampaignData {
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCampaignData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType?: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export class CampaignService {
|
||||
/**
|
||||
* Create a new campaign
|
||||
@@ -85,7 +60,7 @@ export class CampaignService {
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
audienceType: data.audienceType,
|
||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||
segmentId: data.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
@@ -133,7 +108,7 @@ export class CampaignService {
|
||||
if (data.audienceCondition) {
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
|
||||
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
|
||||
}
|
||||
|
||||
if (data.segmentId !== undefined) {
|
||||
@@ -209,7 +184,7 @@ export class CampaignService {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
|
||||
): Promise<PaginatedResponse<Campaign>> {
|
||||
const {status, page = 1, pageSize = 20} = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
@@ -232,7 +207,7 @@ export class CampaignService {
|
||||
]);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
data: campaigns,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -476,6 +451,7 @@ export class CampaignService {
|
||||
const contactData =
|
||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
@@ -589,6 +565,7 @@ export class CampaignService {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
@@ -747,7 +724,7 @@ export class CampaignService {
|
||||
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
||||
|
||||
case CampaignAudienceType.FILTERED: {
|
||||
const condition = campaign.audienceCondition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
|
||||
if (!condition) {
|
||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||
}
|
||||
@@ -783,7 +760,7 @@ export class CampaignService {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
import {isValidLanguageCode} from '@plunk/shared';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {EventService} from './EventService.js';
|
||||
|
||||
export interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export class ContactService {
|
||||
/**
|
||||
* Get all contacts for a project with cursor-based pagination
|
||||
@@ -23,7 +17,7 @@ export class ContactService {
|
||||
limit = 20,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
): Promise<PaginatedContacts> {
|
||||
): Promise<CursorPaginatedResponse<Contact>> {
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
...(search
|
||||
@@ -58,7 +52,7 @@ export class ContactService {
|
||||
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
||||
|
||||
return {
|
||||
contacts: results,
|
||||
data: results,
|
||||
total,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
@@ -227,7 +221,15 @@ export class ContactService {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
@@ -236,10 +238,7 @@ export class ContactService {
|
||||
if (key === 'locale') {
|
||||
if (typeof value === 'string') {
|
||||
if (!isValidLanguageCode(value)) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
|
||||
);
|
||||
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
|
||||
}
|
||||
} else if (value !== null && value !== undefined) {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
@@ -272,7 +271,7 @@ export class ContactService {
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: existing.id},
|
||||
data: {
|
||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
...(subscribed !== undefined ? {subscribed} : {}),
|
||||
},
|
||||
});
|
||||
@@ -292,7 +291,7 @@ export class ContactService {
|
||||
data: {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? true,
|
||||
},
|
||||
});
|
||||
@@ -678,59 +677,11 @@ export class ContactService {
|
||||
return {deletedFrom: result};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
*/
|
||||
public static async bulkSubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number}> {
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
@@ -779,10 +730,7 @@ export class ContactService {
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
*/
|
||||
public static async bulkUnsubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number}> {
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
@@ -829,10 +777,7 @@ export class ContactService {
|
||||
/**
|
||||
* Bulk delete contacts
|
||||
*/
|
||||
public static async bulkDelete(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{deleted: number}> {
|
||||
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
|
||||
const result = await prisma.contact.deleteMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
@@ -843,6 +788,51 @@ export class ContactService {
|
||||
return {deleted: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track events sequentially to avoid database deadlocks
|
||||
* Processes events one at a time with error handling
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||
|
||||
@@ -81,6 +86,31 @@ export class DomainService {
|
||||
|
||||
// Send notification about domain verified
|
||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
// Check deduplication cache
|
||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||
// Set cache to prevent duplicate emails (7 days)
|
||||
await redis.setex(cacheKey, 604800, '1');
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError);
|
||||
}
|
||||
} else if (attributes.status !== 'Success' && domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: {id: domainId},
|
||||
@@ -93,7 +123,41 @@ export class DomainService {
|
||||
});
|
||||
|
||||
// Send notification about domain verification failed
|
||||
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
await NtfyService.notifyDomainVerificationFailed(
|
||||
domain.domain,
|
||||
updatedDomain.project.name,
|
||||
updatedDomain.project.id,
|
||||
);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
// Check deduplication cache (monthly)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent !== '1') {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||
// Set cache to prevent duplicate emails (until end of month)
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
await redis.setex(cacheKey, ttl, '1');
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
|
||||
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
@@ -91,8 +92,8 @@ export class EmailService {
|
||||
fromName: params.fromName,
|
||||
toName: params.toName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
templateId: params.templateId,
|
||||
status: EmailStatus.PENDING,
|
||||
@@ -152,8 +153,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
campaignId: params.campaignId,
|
||||
@@ -213,8 +214,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
workflowExecutionId: params.workflowExecutionId,
|
||||
@@ -250,8 +251,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
workflowExecutionId: params.workflowExecutionId,
|
||||
@@ -332,6 +333,7 @@ export class EmailService {
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
data: {
|
||||
id: email.contact.id,
|
||||
email: email.contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
@@ -502,6 +504,42 @@ export class EmailService {
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Update campaign stats if applicable
|
||||
if (email.campaignId) {
|
||||
const campaignUpdate: Prisma.CampaignUpdateInput = {};
|
||||
|
||||
switch (eventType) {
|
||||
case 'delivered':
|
||||
campaignUpdate.deliveredCount = {increment: 1};
|
||||
break;
|
||||
|
||||
case 'opened':
|
||||
// Only increment unique opens to match getStats logic
|
||||
if (!email.openedAt) {
|
||||
campaignUpdate.openedCount = {increment: 1};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clicked':
|
||||
// Only increment unique clicks to match getStats logic
|
||||
if (!email.clickedAt) {
|
||||
campaignUpdate.clickedCount = {increment: 1};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bounced':
|
||||
campaignUpdate.bouncedCount = {increment: 1};
|
||||
break;
|
||||
}
|
||||
|
||||
if (Object.keys(campaignUpdate).length > 0) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: campaignUpdate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track event
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
@@ -509,7 +547,7 @@ export class EmailService {
|
||||
contactId: email.contactId,
|
||||
emailId: email.id,
|
||||
name: `email.${eventType}`,
|
||||
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
|
||||
data: metadata ? toPrismaJson(metadata) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,27 +1,133 @@
|
||||
import {promises as dns} from 'dns';
|
||||
import {run} from '@zootools/email-spell-checker';
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
import {redis} from '../database/redis.js';
|
||||
|
||||
export interface EmailVerificationResult {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isTypo: boolean;
|
||||
isPlusAddressed: boolean;
|
||||
domainExists: boolean;
|
||||
hasMxRecords: boolean;
|
||||
suggestedEmail?: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const DISPOSABLE_DOMAINS_URL =
|
||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||
|
||||
// Known email forwarding/alias services
|
||||
const FORWARDING_DOMAINS = new Set([
|
||||
'privaterelay.appleid.com', // Apple Sign In
|
||||
'mozmail.com', // Firefox Relay
|
||||
'simplelogin.com', // SimpleLogin
|
||||
'simplelogin.fr',
|
||||
'simplelogin.co',
|
||||
'simplelogin.io',
|
||||
'aleeas.com',
|
||||
'slmail.me',
|
||||
'dralias.com',
|
||||
'8shield.net',
|
||||
'anonaddy.com', // Addy.io
|
||||
'anonaddy.me',
|
||||
'addy.io',
|
||||
'duck.com', // DuckDuckGo
|
||||
'33mail.com', // 33mail
|
||||
'33m.co',
|
||||
'passmail.com', // Proton Pass
|
||||
'passmail.net',
|
||||
'passinbox.com',
|
||||
'passfwd.com',
|
||||
'y.yo.fr',
|
||||
'opayq.com', // IronVest (formerly Blur)
|
||||
'cloak.id', // Cloaked
|
||||
'erine.email', // Erine
|
||||
'use.startmail.com', // StartMail
|
||||
]);
|
||||
|
||||
export class EmailVerificationService {
|
||||
private static disposableDomainsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks if domain exists (DNS A/AAAA records)
|
||||
* - Checks for MX records
|
||||
* - Detects disposable email addresses
|
||||
* - Detects forwarding/alias email addresses
|
||||
* - Suggests corrections for common typos
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isAlias: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
domainExists: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check if email is from a known forwarding/alias service
|
||||
result.isAlias = this.isForwardingDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Check if domain exists (has any DNS records)
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
// Check MX records (only if domain exists)
|
||||
if (result.domainExists) {
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the disposable domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
@@ -76,85 +182,9 @@ export class EmailVerificationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks if domain exists (DNS A/AAAA records)
|
||||
* - Checks for MX records
|
||||
* - Detects disposable email addresses
|
||||
* - Suggests corrections for common typos
|
||||
* Check if a domain is a known forwarding/alias service
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
domainExists: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Check if domain exists (has any DNS records)
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
// Check MX records (only if domain exists)
|
||||
if (result.domainExists) {
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
private static isForwardingDomain(domain: string): boolean {
|
||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {Event} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -32,7 +33,7 @@ export class EventService {
|
||||
contactId,
|
||||
emailId,
|
||||
name: eventName,
|
||||
data: data ? (data as Prisma.InputJsonValue) : undefined,
|
||||
data: data ? toPrismaJson(data) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -472,7 +473,7 @@ export class EventService {
|
||||
contactId,
|
||||
status: 'RUNNING',
|
||||
currentStepId: triggerStep.id,
|
||||
context: context ? (context as Prisma.InputJsonValue) : undefined,
|
||||
context: context ? toPrismaJson(context) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {Membership} from '@plunk/db';
|
||||
import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
|
||||
|
||||
/**
|
||||
* Service for managing project memberships
|
||||
* Centralizes all membership-related database queries with caching
|
||||
*/
|
||||
export class MembershipService {
|
||||
// ============================================
|
||||
// AUTHORIZATION METHODS (Cached)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if user has any access to a project (any role)
|
||||
* CACHED (1 min TTL) - called on every authenticated request
|
||||
*/
|
||||
public static async hasAccess(userId: string, projectId: string): Promise<boolean> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.access(userId, projectId),
|
||||
async () => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return membership !== null;
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin or owner access to a project
|
||||
* CACHED (1 min TTL) - called before write operations
|
||||
*/
|
||||
public static async hasAdminAccess(userId: string, projectId: string): Promise<boolean> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.admin(userId, projectId),
|
||||
async () => {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
return membership !== null;
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's membership with role info
|
||||
* CACHED (1 min TTL) - returns full membership or null
|
||||
*/
|
||||
public static async getMembership(userId: string, projectId: string): Promise<Membership | null> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.full(userId, projectId),
|
||||
async () => {
|
||||
return prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require membership or throw 404
|
||||
* Uses cached getMembership internally
|
||||
*/
|
||||
public static async requireAccess(userId: string, projectId: string): Promise<Membership> {
|
||||
const membership = await this.getMembership(userId, projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin/owner access or throw 403
|
||||
* Uses cached hasAdminAccess internally
|
||||
*/
|
||||
public static async requireAdminAccess(userId: string, projectId: string): Promise<Membership> {
|
||||
const membership = await this.getMembership(userId, projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
|
||||
if (membership.role !== 'ADMIN' && membership.role !== 'OWNER') {
|
||||
throw new HttpException(403, 'Insufficient permissions. Admin or owner access required.');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBER LISTING (Not Cached - Dynamic Data)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get all members of a project with user info
|
||||
* NOT CACHED - returns fresh data for member management UI
|
||||
*/
|
||||
public static async getMembers(projectId: string): Promise<MemberWithEmail[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map(m => ({
|
||||
userId: m.userId,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
createdAt: m.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project owner
|
||||
* CACHED (5 min TTL) - owner rarely changes
|
||||
*/
|
||||
public static async getOwner(projectId: string): Promise<OwnerInfo> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.owner(projectId),
|
||||
async () => {
|
||||
const ownerMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
role: 'OWNER',
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownerMembership) {
|
||||
throw new HttpException(404, 'Project owner not found');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: ownerMembership.userId,
|
||||
email: ownerMembership.user.email,
|
||||
};
|
||||
},
|
||||
FIVE_MINUTES_IN_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRUD OPERATIONS (Invalidate Cache)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Add a member to a project
|
||||
* Invalidates cache for the project
|
||||
*/
|
||||
public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
|
||||
// Check if membership already exists
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new HttpException(409, 'User is already a member of this project');
|
||||
}
|
||||
|
||||
// Create new membership
|
||||
const newMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
projectId,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
|
||||
return newMembership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* Throws if trying to change OWNER role
|
||||
* Invalidates cache
|
||||
*/
|
||||
public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
|
||||
// Get existing membership
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
throw new HttpException(404, 'Membership not found');
|
||||
}
|
||||
|
||||
// Prevent changing OWNER role
|
||||
if (existingMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
||||
}
|
||||
|
||||
// Update role
|
||||
const updatedMembership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: newRole,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
|
||||
return updatedMembership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a project
|
||||
* Throws if trying to remove OWNER
|
||||
* Invalidates cache
|
||||
*/
|
||||
public static async removeMember(projectId: string, userId: string): Promise<void> {
|
||||
// Get existing membership
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
throw new HttpException(404, 'Membership not found');
|
||||
}
|
||||
|
||||
// Prevent removing OWNER
|
||||
if (existingMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot remove the project owner');
|
||||
}
|
||||
|
||||
// Delete membership
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITY METHODS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if user is member of any disabled project
|
||||
* NOT CACHED - security-critical check
|
||||
*/
|
||||
public static async userHasDisabledProject(userId: string): Promise<DisabledProjectInfo> {
|
||||
const disabledMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
project: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hasDisabledProject: disabledMemberships.length > 0,
|
||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRIVATE CACHE MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Invalidate all caches for a project and user
|
||||
* Called after membership changes
|
||||
*/
|
||||
private static async invalidateCache(projectId: string, userId?: string): Promise<void> {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
if (userId) {
|
||||
// Invalidate user-specific caches
|
||||
keysToDelete.push(
|
||||
Keys.Membership.access(userId, projectId),
|
||||
Keys.Membership.admin(userId, projectId),
|
||||
Keys.Membership.full(userId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate project-wide caches
|
||||
keysToDelete.push(Keys.Membership.owner(projectId));
|
||||
|
||||
// Delete all keys
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.del(...keysToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,6 @@
|
||||
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
/**
|
||||
* Priority levels for ntfy notifications
|
||||
* Based on ntfy.sh documentation
|
||||
*/
|
||||
export enum NtfyPriority {
|
||||
MIN = 1, // No vibration/sound, relegated to "Other notifications"
|
||||
LOW = 2, // No vibration/sound, hidden until drawer opened
|
||||
DEFAULT = 3, // Short vibration and sound (standard)
|
||||
HIGH = 4, // Long vibration, pop-over notification
|
||||
MAX = 5, // Long vibration bursts, pop-over notification
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags for ntfy notifications (emoji shortcuts)
|
||||
*/
|
||||
export enum NtfyTag {
|
||||
WARNING = 'warning',
|
||||
ERROR = 'rotating_light',
|
||||
SUCCESS = 'white_check_mark',
|
||||
MONEY = 'money_with_wings',
|
||||
SHIELD = 'shield',
|
||||
ROCKET = 'rocket',
|
||||
BELL = 'bell',
|
||||
CHART = 'chart_with_upwards_trend',
|
||||
SKULL = 'skull',
|
||||
INFO = 'information_source',
|
||||
}
|
||||
|
||||
export interface NtfyNotification {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: NtfyPriority;
|
||||
tags?: NtfyTag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for sending notifications via ntfy.sh
|
||||
* Supports configurable ntfy server URL via NTFY_URL environment variable
|
||||
@@ -241,12 +207,25 @@ export class NtfyService {
|
||||
* Notify about security warning (non-critical)
|
||||
*/
|
||||
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:security:warning:${projectId}`;
|
||||
const exists = await redis.exists(cacheKey);
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningText = warnings.join(', ');
|
||||
await this.sendDefault(
|
||||
'Security Warning',
|
||||
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
||||
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
);
|
||||
|
||||
// Throttle for 1 hour
|
||||
await redis.setex(cacheKey, 3600, '1');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -352,6 +331,19 @@ export class NtfyService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about failed signup attempt with invalid email - LOW priority
|
||||
*/
|
||||
public static async notifyFailedSignupAttempt(email: string, reasons: string[]): Promise<void> {
|
||||
const reasonText = reasons.join(', ');
|
||||
await this.send({
|
||||
title: 'Failed Signup - Invalid Email',
|
||||
message: `Signup attempt blocked for email: ${email}\nReasons: ${reasonText}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about new user account created via OAuth - LOW priority
|
||||
*/
|
||||
@@ -610,11 +602,24 @@ export class NtfyService {
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
||||
const exists = await redis.exists(cacheKey);
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendDefault(
|
||||
'Billing Limit Warning',
|
||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
||||
);
|
||||
|
||||
// Throttle for 24 hours
|
||||
await redis.setex(cacheKey, 86400, '1');
|
||||
}
|
||||
|
||||
// ===== Billing and usage limit notifications =====
|
||||
@@ -629,11 +634,24 @@ export class NtfyService {
|
||||
limit: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
||||
const exists = await redis.exists(cacheKey);
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendUrgent(
|
||||
'Billing Limit Exceeded',
|
||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
||||
);
|
||||
|
||||
// Throttle for 24 hours
|
||||
await redis.setex(cacheKey, 86400, '1');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {Keys} from './keys.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
export class ProjectService {
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.Project.id(id), async () => {
|
||||
return prisma.project.findUnique({where: {id}});
|
||||
});
|
||||
}
|
||||
|
||||
public static async secret(key: string) {
|
||||
return wrapRedis(Keys.Project.secret(key), async () => {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
secret: key,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async public(key: string) {
|
||||
return wrapRedis(Keys.Project.public(key), async () => {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
public: key,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,21 @@
|
||||
import {type Job, Queue} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
import type {
|
||||
ApiRequestCleanupJobData,
|
||||
BulkContactActionJobData,
|
||||
CampaignBatchJobData,
|
||||
ContactImportJobData,
|
||||
DomainVerificationJobData,
|
||||
ScheduledCampaignJobData,
|
||||
SegmentCountJobData,
|
||||
SendEmailJobData,
|
||||
WorkflowStepJobData,
|
||||
} from '@plunk/types';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
/**
|
||||
* Queue Job Data Types
|
||||
*/
|
||||
|
||||
export interface SendEmailJobData {
|
||||
emailId: string;
|
||||
}
|
||||
|
||||
export interface CampaignBatchJobData {
|
||||
campaignId: string;
|
||||
batchNumber: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
cursor?: string; // For cursor-based pagination
|
||||
}
|
||||
|
||||
export interface WorkflowStepJobData {
|
||||
executionId: string;
|
||||
stepId: string;
|
||||
type?: 'process-step' | 'timeout'; // Job type for different handling
|
||||
stepExecutionId?: string; // For timeout jobs, reference to the step execution
|
||||
}
|
||||
|
||||
export interface ScheduledCampaignJobData {
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface ContactImportJobData {
|
||||
projectId: string;
|
||||
csvData: string; // Base64 encoded CSV content
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface SegmentCountJobData {
|
||||
projectId?: string; // Optional: if provided, only update this project's segments
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface DomainVerificationJobData {
|
||||
// Empty for now - processes all domains
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ApiRequestCleanupJobData {
|
||||
// Empty - cleans up old API request logs
|
||||
}
|
||||
|
||||
export interface BulkContactActionJobData {
|
||||
projectId: string;
|
||||
contactIds: string[];
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue Configuration
|
||||
*/
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
CreateBucketCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import crypto from 'crypto';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
S3_ENDPOINT,
|
||||
S3_ACCESS_KEY_ID,
|
||||
S3_ACCESS_KEY_SECRET,
|
||||
S3_BUCKET,
|
||||
S3_PUBLIC_URL,
|
||||
S3_FORCE_PATH_STYLE,
|
||||
S3_ENABLED,
|
||||
S3_ENDPOINT,
|
||||
S3_FORCE_PATH_STYLE,
|
||||
S3_PUBLIC_URL,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
|
||||
let bucketExists = true;
|
||||
|
||||
try {
|
||||
// Check if bucket exists
|
||||
await s3Client.send(
|
||||
new HeadBucketCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
|
||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
@@ -18,10 +19,10 @@ const SECURITY_THRESHOLDS = {
|
||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||
|
||||
// Bounce rate thresholds (hard bounces only)
|
||||
BOUNCE_7DAY_WARNING: 3,
|
||||
BOUNCE_7DAY_CRITICAL: 8,
|
||||
BOUNCE_ALLTIME_WARNING: 2,
|
||||
BOUNCE_ALLTIME_CRITICAL: 5,
|
||||
BOUNCE_7DAY_WARNING: 5,
|
||||
BOUNCE_7DAY_CRITICAL: 10,
|
||||
BOUNCE_ALLTIME_WARNING: 4,
|
||||
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||
|
||||
// Complaint rate thresholds (spam reports)
|
||||
COMPLAINT_7DAY_WARNING: 0.05,
|
||||
@@ -177,26 +178,7 @@ export class SecurityService {
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
const disabledMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
project: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hasDisabledProject: disabledMemberships.length > 0,
|
||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
||||
};
|
||||
return MembershipService.userHasDisabledProject(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,10 +275,11 @@ export class SecurityService {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Only enforce if minimum emails threshold is met
|
||||
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
|
||||
if (hasMinimumVolume) {
|
||||
// Check 7-day bounce rate
|
||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
||||
@@ -306,7 +289,23 @@ export class SecurityService {
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
||||
);
|
||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all-time rates (only if all-time volume is sufficient)
|
||||
if (hasMinimumVolumeAllTime) {
|
||||
// Check all-time bounce rate
|
||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
||||
violations.push(
|
||||
@@ -318,17 +317,6 @@ export class SecurityService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate
|
||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
||||
);
|
||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check all-time complaint rate
|
||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
||||
violations.push(
|
||||
@@ -408,11 +396,8 @@ export class SecurityService {
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -8,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
|
||||
import {EventService} from './EventService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
// Re-export types for use in other services
|
||||
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||
|
||||
export interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert segment name to a URL-safe slug for event names
|
||||
* Example: "VIP Customers" -> "vip-customers"
|
||||
@@ -72,9 +64,9 @@ export class SegmentService {
|
||||
segmentId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedContacts> {
|
||||
): Promise<PaginatedResponse<Contact>> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const skip = (page - 1) * pageSize;
|
||||
@@ -90,7 +82,7 @@ export class SegmentService {
|
||||
]);
|
||||
|
||||
return {
|
||||
contacts,
|
||||
data: contacts,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -122,7 +114,7 @@ export class SegmentService {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
condition: data.condition as unknown as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson(data.condition),
|
||||
trackMembership: data.trackMembership ?? false,
|
||||
memberCount,
|
||||
},
|
||||
@@ -169,7 +161,7 @@ export class SegmentService {
|
||||
updateData.description = data.description;
|
||||
}
|
||||
if (data.condition !== undefined) {
|
||||
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
@@ -237,7 +229,7 @@ export class SegmentService {
|
||||
*/
|
||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
@@ -268,7 +260,7 @@ export class SegmentService {
|
||||
await Promise.all(
|
||||
batch.map(async segment => {
|
||||
try {
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
|
||||
@@ -298,7 +290,7 @@ export class SegmentService {
|
||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||
}
|
||||
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
||||
@@ -364,14 +356,42 @@ export class SegmentService {
|
||||
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
|
||||
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
|
||||
|
||||
// Check which contacts already have a membership record (inactive)
|
||||
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||
where: {
|
||||
segmentId,
|
||||
contactId: {in: batch},
|
||||
},
|
||||
select: {contactId: true},
|
||||
});
|
||||
|
||||
const existingContactIds = new Set(existingMemberships.map(m => m.contactId));
|
||||
const newEntries = batch.filter(id => !existingContactIds.has(id));
|
||||
const reEntries = batch.filter(id => existingContactIds.has(id));
|
||||
|
||||
if (newEntries.length > 0) {
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: batch.map(contactId => ({
|
||||
data: newEntries.map(contactId => ({
|
||||
segmentId,
|
||||
contactId,
|
||||
enteredAt: new Date(),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reEntries.length > 0) {
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {
|
||||
segmentId,
|
||||
contactId: {in: reEntries},
|
||||
},
|
||||
data: {
|
||||
exitedAt: null,
|
||||
enteredAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create segment-specific entry events for each contact in the batch
|
||||
for (const contactId of batch) {
|
||||
@@ -496,6 +516,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)
|
||||
*/
|
||||
@@ -594,19 +627,6 @@ export class SegmentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter condition (recursive)
|
||||
*/
|
||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||
|
||||
if (condition.logic === 'AND') {
|
||||
return {AND: groupClauses};
|
||||
} else {
|
||||
return {OR: groupClauses};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter group (recursive)
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import type {Template} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
export interface PaginatedTemplates {
|
||||
templates: Template[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Get all templates for a project with pagination
|
||||
@@ -23,7 +16,7 @@ export class TemplateService {
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
type?: Template['type'],
|
||||
): Promise<PaginatedTemplates> {
|
||||
): Promise<PaginatedResponse<Template>> {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.TemplateWhereInput = {
|
||||
@@ -51,7 +44,7 @@ export class TemplateService {
|
||||
]);
|
||||
|
||||
return {
|
||||
templates,
|
||||
data: templates,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type {
|
||||
Contact,
|
||||
Prisma,
|
||||
Template,
|
||||
Workflow,
|
||||
WorkflowExecution,
|
||||
WorkflowStep,
|
||||
WorkflowStepExecution,
|
||||
Template,
|
||||
Workflow,
|
||||
} from '@plunk/db';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
|
||||
// Type aliases for workflow execution context
|
||||
type StepConfig = Prisma.JsonValue;
|
||||
type StepResult = Record<string, unknown>;
|
||||
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
|
||||
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
|
||||
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
|
||||
// This is a delayed step - continue with execution
|
||||
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
|
||||
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
|
||||
signale.info(
|
||||
`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`,
|
||||
);
|
||||
return; // Already completed or cancelled
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
|
||||
data: {
|
||||
status: StepExecutionStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
output: result ? (result as Prisma.InputJsonValue) : undefined,
|
||||
output: result ? toPrismaJson(result) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
|
||||
data: {
|
||||
status: StepExecutionStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
output: {
|
||||
output: toPrismaJson({
|
||||
eventName,
|
||||
eventData: data ? (data as Prisma.InputJsonValue) : undefined,
|
||||
eventData: data ? toPrismaJson(data) : undefined,
|
||||
receivedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -543,6 +545,7 @@ export class WorkflowExecutionService {
|
||||
: {};
|
||||
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
...executionContext,
|
||||
@@ -866,7 +869,7 @@ export class WorkflowExecutionService {
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: {
|
||||
data: newData ? (newData as Prisma.InputJsonValue) : undefined,
|
||||
data: newData ? toPrismaJson(newData) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
|
||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -10,34 +12,16 @@ import {EventService} from './EventService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||
|
||||
export interface PaginatedWorkflows {
|
||||
workflows: Workflow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface WorkflowWithDetails extends Workflow {
|
||||
steps: (WorkflowStep & {
|
||||
template?: {id: string; name: string} | null;
|
||||
outgoingTransitions: WorkflowTransition[];
|
||||
incomingTransitions: WorkflowTransition[];
|
||||
})[];
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
|
||||
workflow: Workflow;
|
||||
contact: {id: string; email: string};
|
||||
currentStep?: WorkflowStep | null;
|
||||
stepExecutions: WorkflowStepExecution[];
|
||||
}
|
||||
|
||||
export class WorkflowService {
|
||||
/**
|
||||
* Get all workflows for a project with pagination
|
||||
*/
|
||||
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
|
||||
public static async list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
): Promise<PaginatedResponse<Workflow>> {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.WorkflowWhereInput = {
|
||||
@@ -71,7 +55,7 @@ export class WorkflowService {
|
||||
]);
|
||||
|
||||
return {
|
||||
workflows: workflows as Workflow[],
|
||||
data: workflows as Workflow[],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -321,8 +305,8 @@ export class WorkflowService {
|
||||
workflowId,
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
position: data.position as Prisma.InputJsonValue,
|
||||
config: data.config as Prisma.InputJsonValue,
|
||||
position: toPrismaJson(data.position),
|
||||
config: toPrismaJson(data.config),
|
||||
templateId: data.templateId,
|
||||
},
|
||||
});
|
||||
@@ -403,8 +387,8 @@ export class WorkflowService {
|
||||
const updateData: Prisma.WorkflowStepUpdateInput = {};
|
||||
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
|
||||
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
|
||||
if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
|
||||
if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
|
||||
if (data.templateId !== undefined) {
|
||||
if (data.templateId === null) {
|
||||
updateData.template = {disconnect: true};
|
||||
@@ -596,7 +580,7 @@ export class WorkflowService {
|
||||
fromStepId: data.fromStepId,
|
||||
condition: {
|
||||
path: ['branch'],
|
||||
equals: conditionObj.branch as Prisma.InputJsonValue,
|
||||
equals: toPrismaJson(conditionObj.branch),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import {BillingLimitService} from '../BillingLimitService';
|
||||
import {EmailService} from '../EmailService';
|
||||
@@ -320,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create a date in the previous month
|
||||
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
|
||||
const lastMonth = new Date();
|
||||
lastMonth.setDate(1);
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
|
||||
await prisma.email.create({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignService} from '../CampaignService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('CampaignService', () => {
|
||||
|
||||
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
|
||||
|
||||
expect(result.campaigns).toHaveLength(10);
|
||||
expect(result.data).toHaveLength(10);
|
||||
expect(result.total).toBe(25);
|
||||
expect(result.totalPages).toBe(3);
|
||||
expect(result.page).toBe(1);
|
||||
@@ -177,8 +177,8 @@ describe('CampaignService', () => {
|
||||
|
||||
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
|
||||
|
||||
expect(result.campaigns).toHaveLength(2);
|
||||
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {ContactService} from '../ContactService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
|
||||
await ContactService.bulkSubscribe(project1.id, [
|
||||
p1Contact1.id,
|
||||
p1Contact2.id,
|
||||
p2Contact1.id,
|
||||
p2Contact2.id,
|
||||
]);
|
||||
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
||||
|
||||
const p1ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project1.id},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {DomainService} from '../DomainService.js';
|
||||
import {HttpException} from '../../exceptions/index.js';
|
||||
@@ -110,9 +110,7 @@ describe('DomainService', () => {
|
||||
it('should throw error for invalid email format', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
@@ -122,13 +120,13 @@ describe('DomainService', () => {
|
||||
it('should throw error when domain is not registered', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain belongs to different project', async () => {
|
||||
@@ -141,13 +139,11 @@ describe('DomainService', () => {
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
||||
).rejects.toThrow(/belongs to a different project/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
|
||||
/belongs to a different project/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain is not verified', async () => {
|
||||
@@ -155,13 +151,11 @@ describe('DomainService', () => {
|
||||
|
||||
await DomainService.addDomain(project.id, 'unverified.com');
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not verified/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not verified/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return domain when all checks pass', async () => {
|
||||
@@ -358,9 +352,9 @@ describe('DomainService', () => {
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(
|
||||
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
|
||||
).rejects.toThrow(/domain not found/i);
|
||||
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,9 +385,7 @@ describe('DomainService', () => {
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
||||
/used in.*template/i,
|
||||
);
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
|
||||
});
|
||||
|
||||
it('should throw error when domain is used in active campaigns', async () => {
|
||||
@@ -409,9 +401,7 @@ describe('DomainService', () => {
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
||||
/used in.*campaign/i,
|
||||
);
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
|
||||
});
|
||||
|
||||
it('should allow removal when campaign is SENT (completed)', async () => {
|
||||
@@ -433,9 +423,9 @@ describe('DomainService', () => {
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(
|
||||
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
|
||||
).rejects.toThrow(/domain not found/i);
|
||||
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should check usage in multiple templates', async () => {
|
||||
@@ -492,17 +482,15 @@ describe('DomainService', () => {
|
||||
expect(result.domain).toBe('mail.example.com');
|
||||
|
||||
// Different subdomain should fail
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with no @ sign', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
|
||||
it('should handle email with multiple @ signs', async () => {
|
||||
@@ -516,9 +504,7 @@ describe('DomainService', () => {
|
||||
it('should handle empty email string', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,11 +523,7 @@ describe('DomainService', () => {
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.map(d => d.domain).sort()).toEqual([
|
||||
'concurrent1.com',
|
||||
'concurrent2.com',
|
||||
'concurrent3.com',
|
||||
]);
|
||||
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
|
||||
});
|
||||
|
||||
it('should handle concurrent ownership checks', async () => {
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match exact string values in standard fields (case-insensitive)', async () => {
|
||||
@@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match boolean values', async () => {
|
||||
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match numeric values as strings in JSON fields', async () => {
|
||||
@@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should exclude boolean false values', async () => {
|
||||
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
|
||||
@@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
// notEquals only matches where field exists and has different value
|
||||
expect(ids).toContain(withDifferentValue.id);
|
||||
expect(ids).not.toContain(withMatchingField.id);
|
||||
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match substring in email field (case-insensitive)', async () => {
|
||||
@@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not match when field does not exist', async () => {
|
||||
@@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match partial domain in email', async () => {
|
||||
@@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(gmailUser.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(gmailUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
|
||||
@@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
// notContains only matches where field exists and doesn't contain substring
|
||||
expect(ids).toContain(withDifferentValue.id);
|
||||
expect(ids).not.toContain(withMatchingSubstring.id);
|
||||
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(high.id);
|
||||
expect(ids).toContain(veryHigh.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude values equal to threshold', async () => {
|
||||
@@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should work with negative numbers', async () => {
|
||||
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should work with decimal values', async () => {
|
||||
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(equal.id);
|
||||
expect(ids).toContain(greater.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(low.id);
|
||||
expect(ids).toContain(veryLow.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude values equal to threshold', async () => {
|
||||
@@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(equal.id);
|
||||
expect(ids).toContain(less.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(positive.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(positive.id);
|
||||
});
|
||||
|
||||
it('should handle very large numbers', async () => {
|
||||
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withField.id);
|
||||
});
|
||||
|
||||
it('should exclude contacts where field is null', async () => {
|
||||
@@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withValue.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withValue.id);
|
||||
});
|
||||
|
||||
it('should match fields with empty string values', async () => {
|
||||
@@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withEmptyString.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withEmptyString.id);
|
||||
});
|
||||
|
||||
it('should match fields with zero values', async () => {
|
||||
@@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withZero.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withZero.id);
|
||||
});
|
||||
|
||||
it('should match fields with boolean false values', async () => {
|
||||
@@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withFalse.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withFalse.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withoutField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withoutField.id);
|
||||
});
|
||||
|
||||
it('should match contacts where field is null', async () => {
|
||||
@@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withNull.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withNull.id);
|
||||
});
|
||||
|
||||
it('should exclude fields with empty string values', async () => {
|
||||
@@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should exclude fields with zero values', async () => {
|
||||
@@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(recent.id);
|
||||
});
|
||||
|
||||
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(veryRecent.id);
|
||||
});
|
||||
|
||||
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(justNow.id);
|
||||
});
|
||||
});
|
||||
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with JSON date field within specified hours', async () => {
|
||||
@@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with JSON date field within specified minutes', async () => {
|
||||
@@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT match contacts with JSON date field outside the time range', async () => {
|
||||
@@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT match contacts where JSON date field does not exist', async () => {
|
||||
@@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null values in JSON date fields gracefully', async () => {
|
||||
@@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should work correctly with combined filters (AND logic)', async () => {
|
||||
@@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should require unit parameter for within operator', async () => {
|
||||
@@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(newer.id);
|
||||
expect(ids).not.toContain(older.id);
|
||||
});
|
||||
@@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(first.id);
|
||||
expect(ids).toContain(second.id);
|
||||
expect(ids).not.toContain(third.id);
|
||||
@@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match.id);
|
||||
});
|
||||
|
||||
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should combine existence checks with value comparisons', async () => {
|
||||
@@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should not match contacts who have not triggered the event', async () => {
|
||||
@@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contacts with multiple occurrences of the same event', async () => {
|
||||
@@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with event within time range (hours)', async () => {
|
||||
@@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with event within time range (minutes)', async () => {
|
||||
@@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should handle events at exact boundary', async () => {
|
||||
@@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
// Should not match because events at exact boundary are excluded
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not match contacts with event outside time range', async () => {
|
||||
@@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contact if any of their events is within range', async () => {
|
||||
@@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should not match contacts who have triggered the event', async () => {
|
||||
@@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contacts with other events but not the target event', async () => {
|
||||
@@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should combine multiple event filters', async () => {
|
||||
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(subscribed.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(subscribed.id);
|
||||
});
|
||||
|
||||
it('should filter contacts by custom data fields', async () => {
|
||||
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(proUser.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(proUser.id);
|
||||
});
|
||||
|
||||
it('should filter contacts with multiple conditions', async () => {
|
||||
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(target.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(target.id);
|
||||
});
|
||||
|
||||
it('should support notEquals operator', async () => {
|
||||
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(pro.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(pro.id);
|
||||
});
|
||||
|
||||
it('should support contains operator for strings', async () => {
|
||||
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should support exists operator for custom fields', async () => {
|
||||
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withField.id);
|
||||
});
|
||||
|
||||
it('should handle empty segments', async () => {
|
||||
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
|
||||
expect(page1.contacts).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
|
||||
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
|
||||
expect(page2.contacts).toHaveLength(10);
|
||||
expect(page2.data).toHaveLength(10);
|
||||
|
||||
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
|
||||
expect(page3.contacts).toHaveLength(5);
|
||||
expect(page3.data).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Initially not in segment
|
||||
let result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
|
||||
// Update contact to pro plan
|
||||
await prisma.contact.update({
|
||||
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
|
||||
|
||||
// Should now be in segment
|
||||
result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(contact.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(contact.id);
|
||||
});
|
||||
|
||||
it('should be removed from segment when criteria no longer met', async () => {
|
||||
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Initially in segment
|
||||
let result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
|
||||
// Unsubscribe contact
|
||||
await prisma.contact.update({
|
||||
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Should no longer be in segment
|
||||
result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -506,8 +506,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
|
||||
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
|
||||
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
|
||||
expect(result.data.map(c => c.id)).not.toContain(match.id);
|
||||
});
|
||||
|
||||
it('should support case-insensitive equals/contains for email strings', async () => {
|
||||
@@ -526,7 +526,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
|
||||
const equalsIds = equalsResult.contacts.map(c => c.id);
|
||||
const equalsIds = equalsResult.data.map(c => c.id);
|
||||
expect(equalsIds).toContain(lower.id);
|
||||
expect(equalsIds).toContain(upper.id);
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
|
||||
const containsIds = containsResult.contacts.map(c => c.id);
|
||||
const containsIds = containsResult.data.map(c => c.id);
|
||||
expect(containsIds).toContain(lower.id);
|
||||
expect(containsIds).toContain(upper.id);
|
||||
});
|
||||
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
|
||||
expect(ids).toContain(unsubscribed.id);
|
||||
expect(ids).not.toContain(subscribed.id);
|
||||
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
|
||||
const notContainsIds = notContainsResult.contacts.map(c => c.id);
|
||||
const notContainsIds = notContainsResult.data.map(c => c.id);
|
||||
expect(notContainsIds).toContain(other.id);
|
||||
expect(notContainsIds).not.toContain(acme.id);
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
|
||||
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
|
||||
const notEqualsIds = notEqualsResult.data.map(c => c.id);
|
||||
expect(notEqualsIds).toContain(other.id);
|
||||
expect(notEqualsIds).not.toContain(acme.id);
|
||||
});
|
||||
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
|
||||
const existsIds = new Set(existsResult.contacts.map(c => c.id));
|
||||
const existsIds = new Set(existsResult.data.map(c => c.id));
|
||||
expect(existsIds.has(withCompany.id)).toBe(true);
|
||||
expect(existsIds.has(withNullCompany.id)).toBe(false);
|
||||
|
||||
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
|
||||
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
|
||||
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
|
||||
expect(notExistsIds.has(withCompany.id)).toBe(false);
|
||||
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
|
||||
});
|
||||
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
|
||||
const gtIds = greaterThanResult.contacts.map(c => c.id);
|
||||
const gtIds = greaterThanResult.data.map(c => c.id);
|
||||
expect(gtIds).toContain(mid.id);
|
||||
expect(gtIds).toContain(high.id);
|
||||
expect(gtIds).not.toContain(low.id);
|
||||
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
|
||||
const lteIds = lteResult.contacts.map(c => c.id);
|
||||
const lteIds = lteResult.data.map(c => c.id);
|
||||
expect(lteIds).toContain(low.id);
|
||||
expect(lteIds).toContain(mid.id);
|
||||
expect(lteIds).not.toContain(high.id);
|
||||
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
|
||||
const gtIds = gtResult.contacts.map(c => c.id);
|
||||
const gtIds = gtResult.data.map(c => c.id);
|
||||
expect(gtIds).toContain(newer.id);
|
||||
expect(gtIds).not.toContain(older.id);
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
|
||||
const lteIds = lteResult.contacts.map(c => c.id);
|
||||
const lteIds = lteResult.data.map(c => c.id);
|
||||
expect(lteIds).toContain(older.id);
|
||||
expect(lteIds).toContain(newer.id);
|
||||
});
|
||||
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(recent.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {TemplateType} from '@plunk/db';
|
||||
import {TemplateService} from '../TemplateService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
|
||||
}
|
||||
|
||||
const page1 = await TemplateService.list(projectId, 1, 10);
|
||||
expect(page1.templates).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
|
||||
const page2 = await TemplateService.list(projectId, 2, 10);
|
||||
expect(page2.templates).toHaveLength(10);
|
||||
expect(page2.data).toHaveLength(10);
|
||||
expect(page2.page).toBe(2);
|
||||
|
||||
const page3 = await TemplateService.list(projectId, 3, 10);
|
||||
expect(page3.templates).toHaveLength(5);
|
||||
expect(page3.data).toHaveLength(5);
|
||||
expect(page3.page).toBe(3);
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter templates by search query (description)', async () => {
|
||||
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'new');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.templates.map(t => t.description)).toEqual(
|
||||
expect(result.data.map(t => t.description)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('new')]),
|
||||
);
|
||||
});
|
||||
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
|
||||
|
||||
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
||||
expect(marketingResult.total).toBe(2);
|
||||
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
||||
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
||||
|
||||
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
||||
expect(transactionalResult.total).toBe(1);
|
||||
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||
});
|
||||
|
||||
it('should combine search and type filters', async () => {
|
||||
@@ -223,7 +223,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.templates[0].name).toBe('Welcome Email');
|
||||
expect(result.data[0].name).toBe('Welcome Email');
|
||||
});
|
||||
|
||||
it('should return templates ordered by creation date (newest first)', async () => {
|
||||
@@ -236,9 +236,9 @@ describe('TemplateService', () => {
|
||||
|
||||
const result = await TemplateService.list(projectId, 1, 20);
|
||||
|
||||
expect(result.templates[0].id).toBe(template3.id); // Newest
|
||||
expect(result.templates[1].id).toBe(template2.id);
|
||||
expect(result.templates[2].id).toBe(template1.id); // Oldest
|
||||
expect(result.data[0].id).toBe(template3.id); // Newest
|
||||
expect(result.data[1].id).toBe(template2.id);
|
||||
expect(result.data[2].id).toBe(template1.id); // Oldest
|
||||
});
|
||||
|
||||
it('should only return templates for the specified project', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium Status',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.isPremium',
|
||||
operator: 'equals',
|
||||
value: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Premium Path',
|
||||
position: {x: 200, y: -50},
|
||||
config: {reason: 'Premium customer'},
|
||||
config: toPrismaJson({reason: 'Premium customer'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Standard Path',
|
||||
position: {x: 200, y: 50},
|
||||
config: {reason: 'Standard customer'},
|
||||
config: toPrismaJson({reason: 'Standard customer'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.isPremium',
|
||||
operator: 'equals',
|
||||
value: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Premium',
|
||||
position: {x: 200, y: -50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Standard',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Country',
|
||||
position: {x: 100, y: 0},
|
||||
config: {field: 'data.country', operator: 'equals', value: 'US'},
|
||||
config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium (US)',
|
||||
position: {x: 200, y: -50},
|
||||
config: {field: 'data.isPremium', operator: 'equals', value: true},
|
||||
config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Premium',
|
||||
position: {x: 300, y: -75},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Standard',
|
||||
position: {x: 300, y: -25},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Non-US',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: condition2.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: nonUsExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usPremiumExit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usStandardExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||
name: 'Wait for Purchase',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
eventName: 'purchase.completed',
|
||||
timeout: 3600, // 1 hour
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||
name: 'Wait for Event',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
eventName: 'user.verified',
|
||||
timeout: 3600,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Wait 1 day',
|
||||
position: {x: 100, y: 0},
|
||||
config: {amount: 1, unit: 'days'},
|
||||
config: toPrismaJson({amount: 1, unit: 'days'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Status',
|
||||
position: {x: 200, y: 0},
|
||||
config: {field: 'contact.subscribed', operator: 'equals', value: true},
|
||||
config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: exit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'A/B Split',
|
||||
position: {x: 100, y: 0},
|
||||
config: {field: 'data.segment', operator: 'equals', value: 'A'},
|
||||
config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Path A Delay',
|
||||
position: {x: 200, y: -50},
|
||||
config: {amount: 1, unit: 'hours'},
|
||||
config: toPrismaJson({amount: 1, unit: 'hours'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Path B Delay',
|
||||
position: {x: 200, y: 50},
|
||||
config: {amount: 2, unit: 'hours'},
|
||||
config: toPrismaJson({amount: 2, unit: 'hours'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Merge Point',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathA.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathB.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Bad Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: {}, // Invalid - missing required fields
|
||||
config: toPrismaJson({}), // Invalid - missing required fields
|
||||
},
|
||||
});
|
||||
|
||||
@@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Missing Field',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.nonExistentField',
|
||||
operator: 'equals',
|
||||
value: 'something',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Exit',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Early Exit',
|
||||
position: {x: 100, y: 0},
|
||||
config: {reason: 'User already converted'},
|
||||
config: toPrismaJson({reason: 'User already converted'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.TRIGGER,
|
||||
name: 'Start',
|
||||
position: {x: 0, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
});
|
||||
|
||||
const exitStep = await factories.createWorkflowStep({
|
||||
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'End',
|
||||
position: {x: 100, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: contextData as Prisma.InputJsonValue,
|
||||
context: toPrismaJson(contextData),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check if first open',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.isFirstOpen',
|
||||
operator: 'equals',
|
||||
value: true, // Use boolean, not string
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'First Open',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'first_open'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'first_open'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Not First Open',
|
||||
position: {x: 200, y: 100},
|
||||
config: {reason: 'not_first_open'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'not_first_open'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
|
||||
});
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue},
|
||||
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})},
|
||||
});
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue},
|
||||
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})},
|
||||
});
|
||||
|
||||
// Create execution with event data
|
||||
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome Email',
|
||||
from: '[email protected]',
|
||||
isFirstOpen: true,
|
||||
openedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check subject',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.subject',
|
||||
operator: 'contains',
|
||||
value: 'Welcome',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'matched'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'matched'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome to Plunk!',
|
||||
from: '[email protected]',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check opens count',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.opens',
|
||||
operator: 'greaterThan',
|
||||
value: '3',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'engaged'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'engaged'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Newsletter',
|
||||
opens: 5,
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WEBHOOK,
|
||||
name: 'Send Webhook',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
url: 'https://webhook.example.com/test',
|
||||
method: 'POST',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome Email',
|
||||
from: '[email protected]',
|
||||
messageId: 'msg-123',
|
||||
isFirstOpen: true,
|
||||
openedAt: '2024-01-15T10:00:00Z',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {
|
||||
WorkflowStepType,
|
||||
StepExecutionStatus,
|
||||
WorkflowExecutionStatus,
|
||||
TemplateType,
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowStepType,
|
||||
WorkflowTriggerType,
|
||||
} from '@plunk/db';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
const page1 = await WorkflowService.list(projectId, 1, 10);
|
||||
|
||||
expect(page1.workflows).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ describe('WorkflowService', () => {
|
||||
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include step and execution counts', async () => {
|
||||
@@ -234,8 +234,8 @@ describe('WorkflowService', () => {
|
||||
|
||||
const result = await WorkflowService.list(projectId);
|
||||
|
||||
const found = result.workflows.find(w => w.id === workflow.id) as
|
||||
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}})
|
||||
const found = result.data.find(w => w.id === workflow.id) as
|
||||
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
|
||||
| undefined;
|
||||
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
|
||||
expect(found?._count.executions).toBe(1);
|
||||
|
||||
@@ -26,6 +26,12 @@ export const Keys = {
|
||||
project(projectId: string): string {
|
||||
return `domain:project:${projectId}`;
|
||||
},
|
||||
verifiedEmail(domainId: string): string {
|
||||
return `domain:verified_email:${domainId}`;
|
||||
},
|
||||
unverifiedEmail(domainId: string, year: number, month: string): string {
|
||||
return `domain:unverified_email:${domainId}:${year}-${month}`;
|
||||
},
|
||||
},
|
||||
Billing: {
|
||||
usage(projectId: string, sourceType: string, year: number, month: string): string {
|
||||
@@ -64,4 +70,29 @@ export const Keys = {
|
||||
return `workflows:enabled:${projectId}`;
|
||||
},
|
||||
},
|
||||
Membership: {
|
||||
access(userId: string, projectId: string): string {
|
||||
return `membership:access:${userId}:${projectId}`;
|
||||
},
|
||||
admin(userId: string, projectId: string): string {
|
||||
return `membership:admin:${userId}:${projectId}`;
|
||||
},
|
||||
full(userId: string, projectId: string): string {
|
||||
return `membership:full:${userId}:${projectId}`;
|
||||
},
|
||||
owner(projectId: string): string {
|
||||
return `membership:owner:${projectId}`;
|
||||
},
|
||||
},
|
||||
Project: {
|
||||
id(id: string): string {
|
||||
return `project:id:${id}`;
|
||||
},
|
||||
secret(key: string): string {
|
||||
return `project:secret:${key}`;
|
||||
},
|
||||
public(key: string): string {
|
||||
return `project:public:${key}`;
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
||||
|
||||
// Generate breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||
{position: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
Mail,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
XCircle,
|
||||
Forward,
|
||||
} from 'lucide-react';
|
||||
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
|
||||
|
||||
interface EmailVerificationResultProps {
|
||||
@@ -83,6 +94,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alias/Forwarding Email */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Forward className="h-5 w-5 text-neutral-600" />
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Forwarding Service</p>
|
||||
<p className="text-sm text-neutral-600">Email alias/forwarding detected</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.isAlias ? (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Typo Detection */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
|
||||
const fragment = convertToEmailHtml(html);
|
||||
return wrapEmailHtml(fragment);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface EmailVerificationResult {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isAlias: boolean;
|
||||
isTypo: boolean;
|
||||
isPlusAddressed: boolean;
|
||||
domainExists: boolean;
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -18,10 +18,7 @@ interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
|
||||
) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<VerifyEmailResponse | ErrorResponse>) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({error: 'Method not allowed'});
|
||||
@@ -49,7 +46,7 @@ export default async function handler(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
'Authorization': `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({email}),
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -132,7 +132,7 @@ export default function EmailAPIGuide() {
|
||||
language="javascript"
|
||||
title="Node.js Example"
|
||||
code={`// Using fetch (Node.js 18+ or with node-fetch)
|
||||
const response = await fetch('https://api.useplunk.com/v1/send', {
|
||||
const response = await fetch('https://next-api.useplunk.com/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -167,7 +167,7 @@ if (response.ok) {
|
||||
code={`import requests
|
||||
|
||||
response = requests.post(
|
||||
'https://api.useplunk.com/v1/send',
|
||||
'https://next-api.useplunk.com/v1/send',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY'
|
||||
@@ -195,7 +195,7 @@ else:
|
||||
language="php"
|
||||
title="PHP Example"
|
||||
code={`<?php
|
||||
$ch = curl_init('https://api.useplunk.com/v1/send');
|
||||
$ch = curl_init('https://next-api.useplunk.com/v1/send');
|
||||
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -235,7 +235,7 @@ if ($httpCode === 200) {
|
||||
code={`require 'net/http'
|
||||
require 'json'
|
||||
|
||||
uri = URI('https://api.useplunk.com/v1/send')
|
||||
uri = URI('https://next-api.useplunk.com/v1/send')
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
@@ -295,7 +295,7 @@ func main() {
|
||||
|
||||
jsonData, _ := json.Marshal(email)
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
||||
req, _ := http.NewRequest("POST", "https://next-api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -306,9 +306,7 @@ export default function EmailDeliverability() {
|
||||
{/* Monitoring */}
|
||||
<section id="monitoring" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
Track these metrics to stay on top of deliverability:
|
||||
</p>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">Track these metrics to stay on top of deliverability:</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="15 min"
|
||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="11 min"
|
||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
|
||||
<NextSeo
|
||||
title="Email Marketing & Deliverability Guides | Plunk"
|
||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||
canonical="https://www.useplunk.com/guides"
|
||||
canonical="https://next.useplunk.com/guides"
|
||||
openGraph={{
|
||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
url: 'https://next.useplunk.com/guides',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dmarc"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
|
||||
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
canonical="https://next.useplunk.com/guides/what-is-spf"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -110,8 +110,8 @@ export default function Index() {
|
||||
<span data-custom-class="body_text">
|
||||
Visit our website <bdt className="block-component" /> at{' '}
|
||||
<bdt className="question">
|
||||
<a href="https://www.useplunk.com" target="_blank" data-custom-class="link">
|
||||
https://www.useplunk.com
|
||||
<a href="https://next.useplunk.com" target="_blank" data-custom-class="link">
|
||||
https://next.useplunk.com
|
||||
</a>
|
||||
</bdt>
|
||||
<span style={{fontSize: '15px'}}>
|
||||
|
||||
@@ -390,8 +390,8 @@ export default function Index() {
|
||||
data-id="fdf30452-99b8-c68b-5cdf-34af764cd1fd"
|
||||
data-type="question"
|
||||
>
|
||||
<a href="https://www.useplunk.com" target="_blank" data-custom-class="link">
|
||||
https://www.useplunk.com
|
||||
<a href="https://next.useplunk.com" target="_blank" data-custom-class="link">
|
||||
https://next.useplunk.com
|
||||
</a>
|
||||
</bdt>{' '}
|
||||
website as well as any other media form, media channel, mobile website or mobile application related,
|
||||
@@ -3165,8 +3165,8 @@ export default function Index() {
|
||||
data-id="d10c7fd7-0685-12ac-c717-cbc45ff916d1"
|
||||
data-type="question"
|
||||
>
|
||||
<a href="https://www.useplunk.com/privacy" target="_blank" data-custom-class="link">
|
||||
https://www.useplunk.com/privacy
|
||||
<a href="https://next.useplunk.com/privacy" target="_blank" data-custom-class="link">
|
||||
https://next.useplunk.com/privacy
|
||||
</a>
|
||||
</bdt>
|
||||
</strong>
|
||||
|
||||
@@ -32,13 +32,13 @@ export default function ToolsIndex() {
|
||||
<NextSeo
|
||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
canonical="https://next.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
url: 'https://next.useplunk.com/tools',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function MarkdownToEmail() {
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
canonical="https://next.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
url: 'https://next.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -36,12 +36,12 @@ export default function VerifyEmailPage() {
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
canonical="https://next.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
url: 'https://next.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const faqs: FAQ[] = [
|
||||
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing—you only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk match ActiveCampaign\'s automation capabilities?',
|
||||
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
||||
answer:
|
||||
'For email automation, yes. Plunk supports workflow automation, event-based triggers, and audience segmentation just like ActiveCampaign. The difference is Plunk focuses purely on email, while ActiveCampaign includes CRM, sales automation, and ML-powered recommendations. If you need email automation without sales/CRM features, Plunk delivers the same capability with simpler implementation.',
|
||||
},
|
||||
@@ -45,7 +45,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What ActiveCampaign features does Plunk not have?',
|
||||
answer:
|
||||
'Plunk focuses on email, so it doesn\'t include ActiveCampaign\'s CRM, sales pipeline management, lead scoring, SMS marketing, or machine learning features. If you need those, ActiveCampaign is better. If you want powerful email automation without the enterprise complexity, Plunk is the simpler choice.',
|
||||
"Plunk focuses on email, so it doesn't include ActiveCampaign's CRM, sales pipeline management, lead scoring, SMS marketing, or machine learning features. If you need those, ActiveCampaign is better. If you want powerful email automation without the enterprise complexity, Plunk is the simpler choice.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ActiveCampaignComparison() {
|
||||
<NextSeo
|
||||
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
||||
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
||||
canonical="https://www.useplunk.com/vs/activecampaign"
|
||||
canonical="https://next.useplunk.com/vs/activecampaign"
|
||||
openGraph={{
|
||||
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
url: 'https://next.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -233,9 +233,7 @@ export default function ActiveCampaignComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
Automation Without Complexity
|
||||
</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Automation Without Complexity</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -318,8 +316,8 @@ export default function ActiveCampaignComparison() {
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||
AGPL-3.0 licensed. Full transparency into how your emails are processed. Contribute features,
|
||||
understand the system. ActiveCampaign is proprietary.
|
||||
AGPL-3.0 licensed. Full transparency into how your emails are processed. Contribute features, understand
|
||||
the system. ActiveCampaign is proprietary.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Bento over Plunk?',
|
||||
answer:
|
||||
'Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you\'re a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.',
|
||||
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.",
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Bento?',
|
||||
@@ -45,7 +45,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Is migration from Bento to Plunk easy?',
|
||||
answer:
|
||||
'For email features, yes. Export your contacts from Bento, import them to Plunk, recreate your workflows using our API or dashboard, and integrate Plunk into your application. If you rely heavily on Bento\'s CRM, live chat, or multi-channel features, you\'ll need separate tools for those. Most email-only migrations take a few hours.',
|
||||
"For email features, yes. Export your contacts from Bento, import them to Plunk, recreate your workflows using our API or dashboard, and integrate Plunk into your application. If you rely heavily on Bento's CRM, live chat, or multi-channel features, you'll need separate tools for those. Most email-only migrations take a few hours.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function BentoComparison() {
|
||||
<NextSeo
|
||||
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
||||
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
||||
canonical="https://www.useplunk.com/vs/bento"
|
||||
canonical="https://next.useplunk.com/vs/bento"
|
||||
openGraph={{
|
||||
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
url: 'https://next.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -233,9 +233,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
Developer-First Email Platform
|
||||
</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Developer-First Email Platform</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -339,8 +337,8 @@ export default function BentoComparison() {
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||
Deploy on your infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in.
|
||||
Bento is cloud-only.
|
||||
Deploy on your infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in. Bento
|
||||
is cloud-only.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -360,8 +358,8 @@ export default function BentoComparison() {
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Email-Focused</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||
Do one thing exceptionally well: email. No feature bloat, no CRM complexity, no multi-channel
|
||||
confusion. Just powerful email automation.
|
||||
Do one thing exceptionally well: email. No feature bloat, no CRM complexity, no multi-channel confusion.
|
||||
Just powerful email automation.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -394,9 +392,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
Get focused email automation
|
||||
</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get focused email automation</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
|
||||
required.
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function BrevoComparison() {
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
canonical="https://next.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
url: 'https://next.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'How easy is it to migrate from ConvertKit to Plunk?',
|
||||
answer:
|
||||
'Migration is straightforward. Export your subscribers from ConvertKit as CSV, import them to Plunk via our API or dashboard, recreate your email sequences using our workflow builder, and update your forms to use Plunk\'s API. Most migrations can be completed in a few hours. You\'ll need to rebuild landing pages separately if you rely on ConvertKit\'s landing page feature.',
|
||||
"Migration is straightforward. Export your subscribers from ConvertKit as CSV, import them to Plunk via our API or dashboard, recreate your email sequences using our workflow builder, and update your forms to use Plunk's API. Most migrations can be completed in a few hours. You'll need to rebuild landing pages separately if you rely on ConvertKit's landing page feature.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ConvertkitComparison() {
|
||||
<NextSeo
|
||||
title="ConvertKit Alternative for Developers | Plunk"
|
||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation—no landing page bloat."
|
||||
canonical="https://www.useplunk.com/vs/convertkit"
|
||||
canonical="https://next.useplunk.com/vs/convertkit"
|
||||
openGraph={{
|
||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
url: 'https://next.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -141,9 +141,7 @@ export default function ConvertkitComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
Pay for Emails, Not Subscribers
|
||||
</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pay for Emails, Not Subscribers</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
|
||||
</motion.div>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function CustomerioComparison() {
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
canonical="https://next.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
url: 'https://next.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -96,13 +96,13 @@ export default function CompetitorsIndex() {
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
canonical="https://next.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
url: 'https://next.useplunk.com/vs',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What Klaviyo features does Plunk not have?',
|
||||
answer:
|
||||
'Plunk focuses on email, so it doesn\'t include Klaviyo\'s SMS marketing, native e-commerce platform integrations, AI-powered product recommendations, or predictive analytics. If you need those enterprise features and have the budget, Klaviyo is better. If you want powerful email automation at a fraction of the cost, Plunk delivers.',
|
||||
"Plunk focuses on email, so it doesn't include Klaviyo's SMS marketing, native e-commerce platform integrations, AI-powered product recommendations, or predictive analytics. If you need those enterprise features and have the budget, Klaviyo is better. If you want powerful email automation at a fraction of the cost, Plunk delivers.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function KlaviyoComparison() {
|
||||
<NextSeo
|
||||
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
||||
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
||||
canonical="https://www.useplunk.com/vs/klaviyo"
|
||||
canonical="https://next.useplunk.com/vs/klaviyo"
|
||||
openGraph={{
|
||||
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
url: 'https://next.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user