Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+240
View File
@@ -0,0 +1,240 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared';
import type {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';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {NotFound} from '../exceptions/index.js';
/**
* Public API Actions Controller
* Handles track event and transactional email endpoints
*/
@Controller('v1')
export class Actions {
/**
* POST /v1/track
* Track an event for a contact (creates/updates contact and tracks event)
*
* Request body:
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional, default: true) - Contact subscription status
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
*
* Response:
* - success: boolean
* - data: object with contact ID, event ID, and timestamp
*
* Example:
* {
* event: "purchase",
* email: "user@example.com",
* data: {
* totalSpent: 1500, // Persistent - saved to contact
* plan: "pro", // Persistent - saved to contact
* orderId: {value: "12345", persistent: false}, // Non-persistent - workflows only
* receiptUrl: {value: "https://...", persistent: false} // Non-persistent - workflows only
* }
* }
*/
@Post('track')
@Middleware([requirePublicKey])
public async track(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
const contact = await ContactService.upsert(
auth.projectId,
email,
data as Record<string, unknown> | undefined,
subscribed,
);
// Track the event with ALL data (persistent + non-persistent)
// Non-persistent data flows to workflows via execution context
const eventRecord = await EventService.trackEvent(
auth.projectId,
event,
contact.id,
undefined,
data as Record<string, unknown> | undefined,
);
return res.status(200).json({
success: true,
data: {
contact: contact.id,
event: eventRecord.id,
timestamp: eventRecord.createdAt.toISOString(),
},
});
}
/**
* POST /v1/send
* Send transactional email(s)
*
* Request body:
* - to: string | string[] (required) - Recipient email(s)
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional, default: false) - Contact subscription status
* - name: string (optional) - Sender name
* - from: string (optional) - Sender email (must be from verified domain)
* - reply: string (optional) - Reply-to email
* - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
*
* Response:
* - success: boolean
* - data: object with emails array and timestamp
*
* Example:
* {
* to: "user@example.com",
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
* data: {
* firstName: "John", // Persistent - saved to contact
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
* }
* }
*/
@Post('send')
@Middleware([requireSecretKey])
public async send(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body);
// Normalize recipients to array
const recipients = Array.isArray(to) ? to : [to];
// Fetch template if provided
let emailSubject = subject;
let emailBody = body;
let emailFrom = from;
let emailFromName = name;
let emailReplyTo = reply;
let templateId: string | undefined;
if (template) {
const templateRecord = await prisma.template.findUnique({
where: {
id: template,
projectId: auth.projectId, // Ensure template belongs to this project
},
});
if (!templateRecord) {
throw new NotFound('Template', template);
}
// Use template values, allow overrides from request
emailSubject = subject || templateRecord.subject;
emailBody = body || templateRecord.body;
emailFrom = from || templateRecord.from;
emailFromName = name || templateRecord.fromName || undefined;
emailReplyTo = reply || templateRecord.replyTo || undefined;
templateId = templateRecord.id;
}
// Verify 'from' domain is verified if provided
const senderEmail = emailFrom || 'noreply@useplunk.com'; // Default sender
// Only verify custom domains (not the default noreply@useplunk.com)
if (emailFrom && emailFrom !== 'noreply@useplunk.com') {
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
}
const replyToEmail = emailReplyTo;
const timestamp = new Date();
const emailResults = [];
// Process each recipient
for (const recipientEmail of recipients) {
// Create or update contact with metadata
const contact = await ContactService.upsert(
auth.projectId,
recipientEmail,
data as Record<string, unknown> | undefined,
subscribed,
);
// Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
// Render template with contact data
// Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!;
let renderedBody = emailBody!;
for (const [key, value] of Object.entries(mergedData)) {
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
// Replace with value
const stringValue = value !== null && value !== undefined ? String(value) : '';
renderedSubject = renderedSubject!.replace(placeholder, stringValue);
renderedBody = renderedBody!.replace(placeholder, stringValue);
// Handle fallback syntax: {{field ?? default}}
renderedSubject = renderedSubject!.replace(fallbackPlaceholder, stringValue || '$1');
renderedBody = renderedBody!.replace(fallbackPlaceholder, stringValue || '$1');
}
// Replace any remaining placeholders with empty string or fallback value
renderedSubject = renderedSubject!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
renderedBody = renderedBody!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
// Handle fallback placeholders that weren't matched
renderedSubject = renderedSubject!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
renderedBody = renderedBody!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
const email = await EmailService.sendTransactionalEmail({
projectId: auth.projectId,
contactId: contact.id,
subject: renderedSubject,
body: renderedBody,
from: senderEmail,
fromName: emailFromName,
replyTo: replyToEmail,
headers: headers || undefined,
attachments: attachments || undefined,
templateId: templateId,
});
emailResults.push({
contact: {
id: contact.id,
email: contact.email,
},
email: email.id,
});
}
return res.status(200).json({
success: true,
data: {
emails: emailResults,
timestamp: timestamp.toISOString(),
},
});
}
}
+122
View File
@@ -0,0 +1,122 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
@Controller('activity')
export class Activity {
/**
* GET /activity
* Get unified activity feed for the project
*
* Query params:
* - limit: number (default 50, max 100)
* - cursor: string (pagination cursor: timestamp_id)
* - types: ActivityType[] (filter by activity types)
* - contactId: string (filter by contact)
* - startDate: ISO date string
* - endDate: ISO date string
*/
@Get('')
@Middleware([requireAuth])
public async getActivities(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
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;
// Parse types filter (comma-separated)
let types: ActivityType[] | undefined;
if (req.query.types) {
const typesParam = req.query.types as string;
types = typesParam
.split(',')
.filter(t => Object.values(ActivityType).includes(t as ActivityType)) as ActivityType[];
}
const result = await ActivityService.getActivities(
auth.projectId,
limit,
cursor,
types,
contactId,
startDate,
endDate,
);
return res.status(200).json(result);
}
/**
* GET /activity/stats
* Get activity statistics for the project
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('stats')
@Middleware([requireAuth])
public async getStats(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const stats = await ActivityService.getStats(auth.projectId, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /activity/recent-count
* Get count of recent activities (for real-time updates)
*
* Query params:
* - minutes: number (default 5)
*/
@Get('recent-count')
@Middleware([requireAuth])
public async getRecentCount(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
return res.status(200).json({count, minutes});
}
/**
* GET /activity/types
* Get available activity types (for UI filters)
*/
@Get('types')
@Middleware([requireAuth])
public async getTypes(_req: Request, res: Response) {
const types = Object.values(ActivityType);
return res.status(200).json({types});
}
/**
* GET /activity/upcoming
* Get upcoming scheduled activities (campaigns and workflow emails)
*
* Query params:
* - limit: number (default 50, max 100)
* - daysAhead: number (default 30, max 90)
*/
@Get('upcoming')
@Middleware([requireAuth])
public async getUpcoming(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
const activities = await ActivityService.getUpcomingActivities(auth.projectId, limit, daysAhead);
return res.status(200).json({activities});
}
}
+53
View File
@@ -0,0 +1,53 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js';
@Controller('analytics')
export class Analytics {
/**
* GET /analytics/timeseries
* Get time series data for email analytics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago, max 90 days)
* - endDate: ISO date string (defaults to now)
*
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/
@Get('timeseries')
@Middleware([requireAuth])
public async getTimeSeries(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const timeSeries = await AnalyticsService.getTimeSeriesData(auth.projectId, startDate, endDate);
return res.status(200).json(timeSeries);
}
/**
* GET /analytics/top-campaigns
* Get top performing campaigns by open rate
*
* Query params:
* - limit: number (default 10)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('top-campaigns')
@Middleware([requireAuth])
public async getTopCampaigns(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
const topCampaigns = await AnalyticsService.getTopCampaigns(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topCampaigns);
}
}
+93
View File
@@ -0,0 +1,93 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import type {Request, Response} from 'express';
import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {jwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
@Controller('auth')
export class Auth {
@Post('login')
public async login(req: Request, res: Response) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
const user = await UserService.email(email);
if (!user) {
return res.json({success: false, data: 'Incorrect email or password'});
}
if (user.type === 'PASSWORD' && !user.password) {
return res.json({success: 'redirect', redirect: `/auth/reset?id=${user.id}`});
}
const verified = await AuthService.verifyCredentials(email, password);
if (!verified) {
return res.json({success: false, data: 'Incorrect email or password'});
}
await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
return res
.cookie(UserService.COOKIE_NAME, token, cookie)
.json({success: true, data: {id: user.id, email: user.email}});
}
@Post('signup')
public async signup(req: Request, res: Response) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
const user = await UserService.email(email);
if (user) {
return res.json({
success: false,
data: 'That email is already associated with another user',
});
}
const created_user = await prisma.user.create({
data: {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
},
});
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
success: true,
data: {id: created_user.id, email: created_user.email},
});
}
@Get('logout')
public logout(req: Request, res: Response) {
res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
return res.json(true);
}
@Get('oauth-config')
public oauthConfig(req: Request, res: Response) {
return res.json({
success: true,
data: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
});
}
}
+274
View File
@@ -0,0 +1,274 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared';
import type {Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {type SegmentFilter} from '../services/SegmentService.js';
@Controller('campaigns')
export class Campaigns {
/**
* Create a new campaign
* POST /campaigns
*/
@Post('')
@Middleware([requireAuth])
private async create(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, 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');
}
if (audienceType === CampaignAudienceType.FILTERED && !audienceFilter) {
throw new HttpException(400, 'Audience filter is required for FILTERED audience type');
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId);
const campaign = await CampaignService.create(auth.projectId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
audienceType,
audienceFilter: audienceFilter as SegmentFilter[] | undefined,
segmentId,
});
return res.status(201).json({
success: true,
data: campaign,
});
}
/**
* Get all campaigns for a project
* GET /campaigns
*/
@Get('')
@Middleware([requireAuth])
private async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
// Validate status if provided
if (status && !Object.values(CampaignStatus).includes(status)) {
throw new HttpException(400, 'Invalid status value');
}
const result = await CampaignService.list(auth.projectId, {
status,
page,
pageSize,
});
return res.json({
campaigns: result.campaigns,
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
});
}
/**
* Get a specific campaign
* GET /campaigns/:id
*/
@Get(':id')
@Middleware([requireAuth])
private async get(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const campaign = await CampaignService.get(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
});
}
/**
* Update a campaign
* PUT /campaigns/:id
*/
@Put(':id')
@Middleware([requireAuth])
private async update(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceFilter, segmentId} =
req.body;
// Validate audience-specific fields if audienceType is being updated
if (audienceType === CampaignAudienceType.SEGMENT && segmentId === undefined) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
if (audienceType === CampaignAudienceType.FILTERED && audienceFilter === undefined) {
throw new HttpException(400, 'Audience filter is required for FILTERED audience type');
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId);
}
const campaign = await CampaignService.update(auth.projectId, id!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
audienceType,
audienceFilter,
segmentId,
});
return res.json({
success: true,
data: campaign,
});
}
/**
* Delete a campaign
* DELETE /campaigns/:id
*/
@Delete(':id')
@Middleware([requireAuth])
private async delete(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
await CampaignService.delete(auth.projectId, id!);
return res.json({
success: true,
message: 'Campaign deleted successfully',
});
}
/**
* Duplicate a campaign
* POST /campaigns/:id/duplicate
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
private async duplicate(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const campaign = await CampaignService.duplicate(auth.projectId, id!);
return res.status(201).json({
success: true,
data: campaign,
message: 'Campaign duplicated successfully',
});
}
/**
* Send or schedule a campaign
* POST /campaigns/:id/send
*/
@Post(':id/send')
@Middleware([requireAuth])
private async send(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided
let scheduledDate: Date | undefined;
if (scheduledFor) {
scheduledDate = new Date(scheduledFor);
if (isNaN(scheduledDate.getTime())) {
throw new HttpException(400, 'Invalid scheduledFor date format');
}
}
const campaign = await CampaignService.send(auth.projectId, id!, scheduledDate);
return res.json({
success: true,
data: campaign,
message: scheduledDate ? `Campaign scheduled for ${scheduledDate.toISOString()}` : 'Campaign is being sent',
});
}
/**
* Cancel a campaign
* POST /campaigns/:id/cancel
*/
@Post(':id/cancel')
@Middleware([requireAuth])
private async cancel(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const campaign = await CampaignService.cancel(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
message: 'Campaign cancelled successfully',
});
}
/**
* Get campaign statistics
* GET /campaigns/:id/stats
*/
@Get(':id/stats')
@Middleware([requireAuth])
private async stats(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const stats = await CampaignService.getStats(auth.projectId, id!);
return res.json({
success: true,
data: stats,
});
}
/**
* Send a test email for a campaign
* POST /campaigns/:id/test
*/
@Post(':id/test')
@Middleware([requireAuth])
private async sendTest(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email);
return res.json({
success: true,
message: `Test email sent to ${email}`,
});
}
}
+64
View File
@@ -0,0 +1,64 @@
import {Controller, Get} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
S3_ENABLED,
SMTP_DOMAIN,
SMTP_ENABLED,
SMTP_PORT_SECURE,
SMTP_PORT_SUBMISSION,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI,
} from '../app/constants.js';
@Controller('config')
export class Config {
/**
* GET /config
* Expose a unified view of instance capabilities and feature flags for frontends.
*/
@Get('')
public getConfig(req: Request, res: Response) {
return res.status(200).json({
environment: NODE_ENV,
urls: {
api: API_URI,
dashboard: DASHBOARD_URI,
landing: LANDING_URI,
wiki: WIKI_URI || null,
},
features: {
billing: {
enabled: STRIPE_ENABLED,
},
storage: {
s3Enabled: S3_ENABLED,
},
authProviders: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
email: {
trackingToggleEnabled: TRACKING_TOGGLE_ENABLED,
},
smtp: {
enabled: SMTP_ENABLED,
domain: SMTP_ENABLED ? SMTP_DOMAIN : null,
ports: SMTP_ENABLED
? {
secure: SMTP_PORT_SECURE,
submission: SMTP_PORT_SUBMISSION,
}
: null,
},
},
});
}
}
+313
View File
@@ -0,0 +1,313 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {Request, Response} from 'express';
import multer from 'multer';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
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 {
cb(new Error('Only CSV files are allowed'));
}
},
});
@Controller('contacts')
export class Contacts {
/**
* GET /contacts
* List all contacts for the authenticated project with cursor-based pagination
*/
@Get('')
@Middleware([requireAuth])
public async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
const result = await ContactService.list(auth.projectId!, limit, cursor, search);
return res.status(200).json(result);
}
/**
* GET /contacts/fields
* Get all available contact fields (both standard and custom fields from data JSON)
*/
@Get('fields')
@Middleware([requireAuth])
public async getAvailableFields(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
try {
const fields = await ContactService.getAvailableFields(auth.projectId!);
return res.status(200).json({
fields,
count: fields.length,
});
} catch (error) {
console.error('[CONTACTS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /contacts/fields/:field/values
* Get unique values for a contact field (for workflow conditions, segment filters, etc.)
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/
@Get('fields/:field/values')
@Middleware([requireAuth])
public async getFieldValues(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const values = await ContactService.getUniqueFieldValues(auth.projectId!, field, limit);
return res.status(200).json({
field,
values,
count: values.length,
limit,
});
} catch (error) {
console.error('[CONTACTS] Failed to get field values:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values',
});
}
}
/**
* GET /contacts/:id
* Get a specific contact by ID
*/
@Get(':id')
@Middleware([requireAuth])
public async get(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.get(auth.projectId!, contactId);
return res.status(200).json(contact);
}
/**
* POST /contacts
* Create or update a contact (upsert)
*/
@Post('')
@Middleware([requireAuth])
public async create(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {email, data, subscribed} = req.body;
if (!email) {
return res.status(400).json({error: 'Email is required'});
}
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(auth.projectId!, email);
const isUpdate = !!existingContact;
const contact = await ContactService.upsert(auth.projectId!, email, data, subscribed);
return res.status(isUpdate ? 200 : 201).json({
...contact,
_meta: {
isNew: !isUpdate,
isUpdate,
},
});
}
/**
* PATCH /contacts/:id
* Update a contact
*/
@Patch(':id')
@Middleware([requireAuth])
public async update(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
const {email, data, subscribed} = req.body;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.update(auth.projectId!, contactId, {email, data, subscribed});
return res.status(200).json(contact);
}
/**
* DELETE /contacts/:id
* Delete a contact
*/
@Delete(':id')
@Middleware([requireAuth])
public async delete(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
await ContactService.delete(auth.projectId!, contactId);
return res.status(204).send();
}
/**
* GET /contacts/public/:id
* PUBLIC: Get contact information (no auth required)
*/
@Get('public/:id')
public async getPublic(req: Request, res: Response) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.getById(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/public/:id/subscribe
* PUBLIC: Subscribe a contact (no auth required)
*/
@Post('public/:id/subscribe')
public async subscribePublic(req: Request, res: Response) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.subscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/public/:id/unsubscribe
* PUBLIC: Unsubscribe a contact (no auth required)
*/
@Post('public/:id/unsubscribe')
public async unsubscribePublic(req: Request, res: Response) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.unsubscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/import
* Import contacts from CSV file
*/
@Post('import')
@Middleware([requireAuth, upload.single('file')])
public async importCsv(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
if (!req.file) {
return res.status(400).json({error: 'CSV file is required'});
}
try {
// Convert file buffer to base64 for storage in queue
const csvData = req.file.buffer.toString('base64');
const filename = req.file.originalname;
// Queue import job
const job = await QueueService.queueImport(auth.projectId!, csvData, filename);
return res.status(202).json({
message: 'Import queued successfully',
jobId: job.id,
});
} catch (error) {
console.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import',
});
}
}
/**
* GET /contacts/import/:jobId
* Get import job status
*/
@Get('import/:jobId')
@Middleware([requireAuth])
public async getImportStatus(req: Request, res: Response) {
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getImportJobStatus(jobId);
if (!status) {
return res.status(404).json({error: 'Import job not found'});
}
return res.status(200).json(status);
} catch (error) {
console.error('[CONTACTS] Failed to get import status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status',
});
}
}
}
+173
View File
@@ -0,0 +1,173 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
import type {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} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js';
@Controller('domains')
export class Domains {
/**
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated])
public async getProjectDomains(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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');
}
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains);
}
/**
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated])
public async addDomain(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) {
throw new NotFound('User authentication required');
}
// 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');
}
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) {
// If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) {
return res.status(400).json({
error: `This domain is already linked to project "${ownershipCheck.projectName}". You are a member of that project, so you can use the domain from there.`,
});
}
// Domain exists but user is not a member - deny access
return res.status(403).json({
error: 'This domain is already linked to another project. Only members of that project can use this domain.',
});
}
try {
const newDomain = await DomainService.addDomain(projectId, domain);
await redis.del(Keys.Domain.project(projectId));
return res.status(201).json(newDomain);
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({error: error.message});
}
throw error;
}
}
/**
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated])
public async checkVerification(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// 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');
}
const verificationStatus = await DomainService.checkVerification(id);
// Invalidate cache if status changed
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json(verificationStatus);
}
/**
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated])
public async removeDomain(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// 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 DomainService.removeDomain(id);
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json({success: true});
}
}
+94
View File
@@ -0,0 +1,94 @@
import {Controller, Get, Middleware, Post} from '@overnightjs/core';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js';
@Controller('events')
export class Events {
/**
* POST /events/track
* Track a custom event (can trigger workflows)
*/
@Post('track')
@Middleware([requireAuth])
public async track(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {name, contactId, emailId, data} = req.body;
if (!name) {
return res.status(400).json({error: 'Event name is required'});
}
const event = await EventService.trackEvent(auth.projectId!, name, contactId, emailId, data);
return res.status(201).json(event);
}
/**
* GET /events
* List events for the project
*/
@Get('')
@Middleware([requireAuth])
public async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100;
const events = await EventService.getProjectEvents(auth.projectId!, eventName, limit);
return res.status(200).json({events});
}
/**
* GET /events/stats
* Get event statistics
*/
@Get('stats')
@Middleware([requireAuth])
public async stats(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const stats = await EventService.getEventStats(auth.projectId!, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /events/contact/:contactId
* Get events for a specific contact
*/
@Get('contact/:contactId')
@Middleware([requireAuth])
public async getContactEvents(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const events = await EventService.getContactEvents(auth.projectId!, contactId, limit);
return res.status(200).json({events});
}
/**
* GET /events/names
* Get unique event names for the project
*/
@Get('names')
@Middleware([requireAuth])
public async getEventNames(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
return res.status(200).json({eventNames});
}
}
+79
View File
@@ -0,0 +1,79 @@
import {Controller, Get} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GITHUB_OAUTH_CLIENT,
GITHUB_OAUTH_ENABLED,
GITHUB_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {UserService} from '../../services/UserService.js';
@Controller('github')
export class Github {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const OAUTH_QS = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
redirect_uri: `${API_URI}/oauth/github/callback`,
response_type: 'code',
scope: 'user:email',
});
return res.redirect(`https://github.com/login/oauth/authorize?${OAUTH_QS.toString()}`);
}
@Get('callback')
public async callback(req: Request, res: Response) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const {code} = req.query;
const data = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
client_secret: GITHUB_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/github/callback`,
});
const {access_token, token_type} = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
body: data,
}).then(res => res.json());
const emails = await fetch(`https://api.github.com/user/emails`, {
headers: {Authorization: `${token_type} ${access_token}`},
}).then(res => res.json());
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email;
let user = await UserService.email(email as string);
if (!user) {
user = await prisma.user.create({
data: {
email,
type: 'GITHUB_OAUTH',
},
});
}
if (user.type !== 'GITHUB_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+73
View File
@@ -0,0 +1,73 @@
import {Controller, Get} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GOOGLE_OAUTH_CLIENT,
GOOGLE_OAUTH_ENABLED,
GOOGLE_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {UserService} from '../../services/UserService.js';
@Controller('google')
export class Google {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
return res.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.email&access_type=offline&include_granted_scopes=true&prompt=select_account&response_type=code&redirect_uri=${API_URI}/oauth/google/callback&client_id=${GOOGLE_OAUTH_CLIENT}`,
);
}
@Get('callback')
public async callback(req: Request, res: Response) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
const {code} = req.query;
const data = new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT,
client_secret: GOOGLE_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/google/callback`,
grant_type: 'authorization_code',
});
const {access_token} = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: data,
}).then(res => res.json());
const {email} = await fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`).then(
res => res.json(),
);
let user = await UserService.email(email);
if (!user) {
user = await prisma.user.create({
data: {
email,
type: 'GOOGLE_OAUTH',
},
});
}
if (user.type !== 'GOOGLE_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+8
View File
@@ -0,0 +1,8 @@
import {ChildControllers, Controller} from '@overnightjs/core';
import {Github} from './Github.js';
import {Google} from './Google.js';
@Controller('oauth')
@ChildControllers([new Google(), new Github()])
export class Oauth {}
+129
View File
@@ -0,0 +1,129 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
@Controller('projects')
export class Projects {
/**
* Get project setup state for dashboard quick start
* GET /projects/:id/setup-state
*/
@Get(':id/setup-state')
@Middleware([requireAuth])
private async getSetupState(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = 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');
}
// Get project with relevant data
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
_count: {
select: {
contacts: true,
domains: {
where: {verified: true},
},
workflows: {
where: {enabled: true},
},
},
},
},
});
if (!project) {
throw new HttpException(404, 'Project not found');
}
// Get last sent campaign
const lastCampaign = await prisma.campaign.findFirst({
where: {
projectId: id,
status: 'SENT',
sentAt: {not: null},
},
orderBy: {
sentAt: 'desc',
},
select: {
sentAt: true,
},
});
return res.json({
success: true,
data: {
hasSubscription: !!project.subscription,
hasVerifiedDomain: project._count.domains > 0,
contactCount: project._count.contacts,
lastCampaignSentAt: lastCampaign?.sentAt || null,
hasEnabledWorkflow: project._count.workflows > 0,
},
});
}
/**
* Get all members of a project
* GET /projects/:id/members
*/
@Get(':id/members')
@Middleware([requireAuth])
private async getMembers(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = 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');
}
// Get all members of the project
const members = await prisma.membership.findMany({
where: {
projectId: id,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
return res.json({
success: true,
data: members.map(m => ({
userId: m.user.id,
email: m.user.email,
role: m.role,
})),
});
}
}
+177
View File
@@ -0,0 +1,177 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js';
@Controller('segments')
export class Segments {
/**
* GET /segments
* List all segments for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
public async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segments = await SegmentService.list(auth.projectId!);
return res.status(200).json(segments);
}
/**
* GET /segments/:id
* Get a specific segment by ID with member count
*/
@Get(':id')
@Middleware([requireAuth])
public async get(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const segment = await SegmentService.get(auth.projectId!, segmentId);
return res.status(200).json(segment);
}
/**
* GET /segments/:id/contacts
* Get contacts that match a segment's filters
*/
@Get(':id/contacts')
@Middleware([requireAuth])
public async getContacts(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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);
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.getContacts(auth.projectId!, segmentId, page, pageSize);
return res.status(200).json(result);
}
/**
* POST /segments
* Create a new segment
*/
@Post('')
@Middleware([requireAuth])
public async create(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {name, description, filters, trackMembership} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!filters || !Array.isArray(filters)) {
return res.status(400).json({error: 'Filters must be an array'});
}
const segment = await SegmentService.create(auth.projectId!, {
name,
description,
filters,
trackMembership,
});
return res.status(201).json(segment);
}
/**
* PATCH /segments/:id
* Update a segment
*/
@Patch(':id')
@Middleware([requireAuth])
public async update(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
const {name, description, filters, trackMembership} = req.body;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
if (filters !== undefined && !Array.isArray(filters)) {
return res.status(400).json({error: 'Filters must be an array'});
}
const segment = await SegmentService.update(auth.projectId!, segmentId, {
name,
description,
filters,
trackMembership,
});
return res.status(200).json(segment);
}
/**
* DELETE /segments/:id
* Delete a segment
*/
@Delete(':id')
@Middleware([requireAuth])
public async delete(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
await SegmentService.delete(auth.projectId!, segmentId);
return res.status(204).send();
}
/**
* POST /segments/:id/compute
* Recompute segment membership for all contacts
*/
@Post(':id/compute')
@Middleware([requireAuth])
public async compute(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.computeMembership(auth.projectId!, segmentId);
return res.status(200).json(result);
}
/**
* POST /segments/:id/refresh
* Refresh segment member count
*/
@Post(':id/refresh')
@Middleware([requireAuth])
public async refresh(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const memberCount = await SegmentService.refreshMemberCount(auth.projectId!, segmentId);
return res.status(200).json({memberCount});
}
}
+182
View File
@@ -0,0 +1,182 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
@Controller('templates')
export class Templates {
/**
* GET /templates
* List all templates for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
public async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
const type = req.query.type as TemplateType | undefined;
const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type);
return res.status(200).json(result);
}
/**
* GET /templates/:id
* Get a specific template by ID
*/
@Get(':id')
@Middleware([requireAuth])
public async get(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.get(auth.projectId!, templateId);
return res.status(200).json(template);
}
/**
* POST /templates
* Create a new template
*/
@Post('')
@Middleware([requireAuth])
public async create(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!subject) {
return res.status(400).json({error: 'Subject is required'});
}
if (!body) {
return res.status(400).json({error: 'Body is required'});
}
if (!from) {
return res.status(400).json({error: 'From address is required'});
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId!);
const template = await TemplateService.create(auth.projectId!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(201).json(template);
}
/**
* PATCH /templates/:id
* Update a template
*/
@Patch(':id')
@Middleware([requireAuth])
public async update(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId!);
}
const template = await TemplateService.update(auth.projectId!, templateId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(200).json(template);
}
/**
* DELETE /templates/:id
* Delete a template
*/
@Delete(':id')
@Middleware([requireAuth])
public async delete(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
await TemplateService.delete(auth.projectId!, templateId);
return res.status(204).send();
}
/**
* POST /templates/:id/duplicate
* Duplicate a template
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
public async duplicate(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.duplicate(auth.projectId!, templateId);
return res.status(201).json(template);
}
/**
* GET /templates/:id/usage
* Get template usage statistics
*/
@Get(':id/usage')
@Middleware([requireAuth])
public async getUsage(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const usage = await TemplateService.getUsage(auth.projectId!, templateId);
return res.status(200).json(usage);
}
}
+73
View File
@@ -0,0 +1,73 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import type {Request, Response} from 'express';
import multer from 'multer';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
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)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
}
},
});
@Controller('uploads')
export class Uploads {
/**
* POST /uploads/image
* Upload an image file to S3/Minio
*/
@Post('image')
@Middleware([requireAuth, upload.single('image')])
public async uploadImage(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
try {
if (!S3Service.isS3Enabled()) {
return res.status(503).json({
error: 'File uploads are not enabled. Please configure S3 storage.',
});
}
if (!req.file) {
return res.status(400).json({
error: 'No image file provided',
});
}
// Upload file to S3/Minio
const result = await S3Service.uploadFile({
file: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
projectId: auth.projectId!,
});
return res.status(200).json({
url: result.url,
key: result.key,
filename: req.file.originalname,
contentType: req.file.mimetype,
size: req.file.size,
});
} catch (error) {
console.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image',
});
}
}
}
+604
View File
@@ -0,0 +1,604 @@
import {randomBytes} from 'node:crypto';
import {Controller, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
import type {Request, Response} from 'express';
import {
DASHBOARD_URI,
SMTP_DOMAIN,
SMTP_ENABLED,
SMTP_PORT_SECURE,
SMTP_PORT_SUBMISSION,
STRIPE_ENABLED,
STRIPE_PRICE_EMAIL_USAGE,
STRIPE_PRICE_ONBOARDING,
TRACKING_TOGGLE_ENABLED,
} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js';
@Controller('users')
export class Users {
@Get('@me')
@Middleware([isAuthenticated])
public async me(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
const me = await UserService.id(auth.userId);
if (!me) {
throw new NotAuthenticated();
}
return res.status(200).json({id: me.id, email: me.email});
}
@Get('@me/projects')
@Middleware([isAuthenticated])
public async meProjects(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
const projects = await UserService.projects(auth.userId);
return res.status(200).json(projects);
}
@Post('@me/projects')
@Middleware([isAuthenticated])
public async createProject(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Create the project
const project = await prisma.project.create({
data: {
name,
public: publicKey,
secret: secretKey,
members: {
create: {
userId: auth.userId,
role: 'ADMIN',
},
},
},
});
return res.status(201).json(project);
}
@Patch('@me/projects/:id')
@Middleware([isAuthenticated])
public async updateProject(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = 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');
}
// Update the project
const project = await prisma.project.update({
where: {id},
data,
});
return res.status(200).json(project);
}
@Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated])
public async regenerateProjectKeys(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = 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');
}
// Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Update the project with new keys
const project = await prisma.project.update({
where: {id},
data: {
public: publicKey,
secret: secretKey,
},
});
return res.status(200).json(project);
}
@Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated])
public async createCheckoutSession(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
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');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// If project already has a subscription, return error
if (project.subscription) {
return res.status(400).json({error: 'Project already has a subscription'});
}
// Build line items for subscription
const lineItems = [];
// Add one-time onboarding fee if configured
if (STRIPE_PRICE_ONBOARDING) {
lineItems.push({price: STRIPE_PRICE_ONBOARDING, quantity: 1});
}
// Add metered pricing for pay-per-email (required)
if (!STRIPE_PRICE_EMAIL_USAGE) {
return res.status(500).json({error: 'Usage-based pricing not configured. Set STRIPE_PRICE_EMAIL_USAGE.'});
}
lineItems.push({price: STRIPE_PRICE_EMAIL_USAGE}); // No quantity for metered items
// Calculate billing cycle anchor to first day of next month
// This ensures all subscriptions renew on the 1st of each month
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
// Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
success_url: `${DASHBOARD_URI}/settings?tab=billing&success=true`,
cancel_url: `${DASHBOARD_URI}/settings?tab=billing&canceled=true`,
});
return res.status(200).json({url: session.url});
}
@Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated])
public async createBillingPortalSession(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
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');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// Project must have a customer ID to access billing portal
if (!project.customer) {
return res.status(400).json({error: 'No customer found for this project'});
}
// Create billing portal session
const session = await stripe.billingPortal.sessions.create({
customer: project.customer,
return_url: `${DASHBOARD_URI}/settings?tab=billing`,
});
return res.status(200).json({url: session.url});
}
@Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
public async getBillingLimits(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// 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');
}
// Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
public async updateBillingLimits(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
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');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
// Require active subscription to set billing limits
if (!project.subscription) {
return res.status(400).json({error: 'An active subscription is required to set billing limits'});
}
// Update billing limits
await prisma.project.update({
where: {id},
data: {
billingLimitWorkflows: data.workflows,
billingLimitCampaigns: data.campaigns,
billingLimitTransactional: data.transactional,
},
});
// Invalidate cache so new limits take effect immediately
await BillingLimitService.invalidateCache(id);
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated])
public async getBillingConsumption(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// 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');
}
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
subscription: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer || !project.subscription) {
return res.status(400).json({error: 'No active subscription found'});
}
// Get the current billing period (start of current month to now)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Get upcoming invoice to see costs and usage
let upcomingInvoice = null;
let totalUsage = 0;
try {
upcomingInvoice = (await (stripe.invoices as any).retrieveUpcoming({customer: project.customer})) as any;
// Extract metered usage from invoice line items
if (upcomingInvoice && upcomingInvoice.lines && upcomingInvoice.lines.data) {
const meteredLine = upcomingInvoice.lines.data.find((line: any) => {
const price = line.price;
return price && typeof price === 'object' && 'id' in price && price.id === STRIPE_PRICE_EMAIL_USAGE;
});
if (meteredLine && meteredLine.quantity) {
totalUsage = meteredLine.quantity;
}
}
} catch {
// No upcoming invoice yet or error retrieving it
// This is normal for new subscriptions or if there's no usage yet
}
return res.status(200).json({
period: {
start: startOfMonth.toISOString(),
end: now.toISOString(),
},
usage: {
total: totalUsage,
records: [], // Deprecated in favor of invoice line items
},
upcomingInvoice: upcomingInvoice
? {
amountDue: upcomingInvoice.amount_due,
currency: upcomingInvoice.currency,
periodStart: upcomingInvoice.period_start
? new Date(upcomingInvoice.period_start * 1000).toISOString()
: startOfMonth.toISOString(),
periodEnd: upcomingInvoice.period_end
? new Date(upcomingInvoice.period_end * 1000).toISOString()
: now.toISOString(),
subtotal: upcomingInvoice.subtotal ?? 0,
total: upcomingInvoice.total ?? 0,
}
: null,
});
}
@Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated])
public async getBillingInvoices(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// 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');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer) {
return res.status(400).json({error: 'No customer found'});
}
// Get all invoices for this customer
const invoices = await stripe.invoices.list({
customer: project.customer,
limit: 100,
});
// Check for unpaid invoices
const unpaidInvoices = invoices.data.filter(
invoice => invoice.status === 'open' || invoice.status === 'uncollectible',
);
return res.status(200).json({
invoices: invoices.data.map(invoice => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: new Date(invoice.created * 1000).toISOString(),
periodStart: invoice.period_start ? new Date(invoice.period_start * 1000).toISOString() : null,
periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
subtotal: invoice.subtotal,
total: invoice.total,
paid: invoice.status === 'paid',
})),
hasUnpaidInvoices: unpaidInvoices.length > 0,
unpaidInvoices: unpaidInvoices.map(invoice => ({
id: invoice.id,
number: invoice.number,
amountDue: invoice.amount_due,
currency: invoice.currency,
dueDate: invoice.due_date ? new Date(invoice.due_date * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
})),
});
}
@Get('@me/projects/:id/security')
@Middleware([isAuthenticated])
public async getSecurityHealth(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// 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');
}
// Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.status(200).json(metrics);
}
}
+301
View File
@@ -0,0 +1,301 @@
import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import signale from 'signale';
import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {SecurityService} from '../services/SecurityService.js';
/**
* Webhooks Controller
* Handles incoming webhooks from external services (AWS SNS/SES)
*/
@Controller('webhooks')
export class Webhooks {
/**
* Receive SNS webhook notifications from AWS SES
* Handles email events: delivery, open, click, bounce, complaint
*/
@Post('sns')
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Parse the SNS message body
const body = JSON.parse(req.body.Message);
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId;
if (!messageId) {
signale.warn('[WEBHOOK] No messageId found in SNS notification');
return res.status(400).json({success: false, error: 'No messageId found'});
}
// Look up email by SES messageId
const email = await prisma.email.findUnique({
where: {messageId},
include: {
contact: true,
project: true,
},
});
if (!email) {
signale.warn(`[WEBHOOK] Email not found for messageId: ${messageId}`);
return res.status(404).json({success: false, error: 'Email not found'});
}
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
const eventName = `email.${eventType.toLowerCase()}`;
let eventData: Record<string, unknown> = {};
// Process event based on type
switch (eventType) {
case 'Delivery':
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
break;
case 'Open':
signale.success(`[WEBHOOK] Open received for ${email.contact.email} from ${email.project.name}`);
// Only set openedAt on first open
if (!email.openedAt) {
updateData.openedAt = now;
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
break;
case 'Click': {
signale.success(`[WEBHOOK] Click received for ${email.contact.email} from ${email.project.name}`);
const clickedLink = body.click?.link;
// Only set clickedAt on first click
if (!email.clickedAt) {
updateData.clickedAt = now;
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
eventData = {link: clickedLink};
break;
}
case 'Bounce':
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = body.bounce ? {bounceType: body.bounce.bounceType} : {};
break;
case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact on complaint
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
break;
default:
signale.warn(`[WEBHOOK] Unknown event type: ${eventType}`);
return res.status(200).json({success: true});
}
// Update email with new status and timestamps
await prisma.email.update({
where: {id: email.id},
data: updateData,
});
// Track event
await prisma.event.create({
data: {
projectId: email.projectId,
contactId: email.contactId,
emailId: email.id,
name: eventName,
data: eventData as Prisma.InputJsonValue,
},
});
// Check security limits for bounce and complaint events
if (eventType === 'Bounce' || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
}
signale.success(`[WEBHOOK] Processed ${eventType} event for email ${email.id}`);
return res.status(200).json({success: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing SNS webhook:', error);
// Always return 200 to prevent SNS from retrying
return res.status(200).json({success: true});
}
}
/**
* Receive Stripe webhook notifications
* Handles subscription and payment events: checkout.session.completed, invoice.paid, etc.
*/
@Post('incoming/stripe')
public async receiveStripeWebhook(req: Request, res: Response) {
// Return 404 if billing is disabled
if (!STRIPE_ENABLED || !stripe) {
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
return res.status(404).json({success: false, error: 'Billing is disabled'});
}
try {
const sig = req.headers['stripe-signature'];
if (!sig) {
signale.warn('[WEBHOOK] Missing Stripe signature header');
return res.status(400).json({success: false, error: 'Missing signature'});
}
// Verify webhook signature using raw body
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
signale.error('[WEBHOOK] Stripe signature verification failed:', err);
return res.status(400).json({success: false, error: 'Invalid signature'});
}
signale.info(`[WEBHOOK] Received Stripe event: ${event.type}`);
// Handle different event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const projectId = session.client_reference_id; // Assuming project ID is passed as reference
if (!projectId) {
signale.warn('[WEBHOOK] No client_reference_id in checkout session');
break;
}
// Update project with customer and subscription IDs
await prisma.project.update({
where: {id: projectId},
data: {
customer: customerId,
subscription: subscriptionId,
},
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., extend trial, update billing status)
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., disable project, send notification)
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
// Clear subscription from project
await prisma.project.update({
where: {id: project.id},
data: {
subscription: null,
},
});
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Additional logic can be added here (e.g., downgrade to free plan)
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
signale.info(`[WEBHOOK] Subscription updated for project ${project.name} (${project.id})`);
signale.info(
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
);
// Additional logic can be added here (e.g., update subscription status, handle plan changes)
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
break;
}
return res.status(200).json({success: true, received: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing Stripe webhook:', error);
return res.status(400).json({success: false, error: 'Webhook processing failed'});
}
}
}
+333
View File
@@ -0,0 +1,333 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
@Controller('workflows')
export class Workflows {
/**
* GET /workflows
* List all workflows for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
public async list(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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;
const result = await WorkflowService.list(auth.projectId!, page, pageSize, search);
return res.status(200).json(result);
}
/**
* GET /workflows/:id
* Get a specific workflow with all steps and transitions
*/
@Get(':id')
@Middleware([requireAuth])
public async get(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.get(auth.projectId!, workflowId);
return res.status(200).json(workflow);
}
/**
* POST /workflows
* Create a new workflow
*/
@Post('')
@Middleware([requireAuth])
public async create(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
const workflow = await WorkflowService.create(auth.projectId!, {
name,
description,
eventName,
enabled,
allowReentry,
});
return res.status(201).json(workflow);
}
/**
* PATCH /workflows/:id
* Update a workflow
*/
@Patch(':id')
@Middleware([requireAuth])
public async update(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.update(auth.projectId!, workflowId, {
name,
description,
triggerType,
triggerConfig,
enabled,
allowReentry,
});
return res.status(200).json(workflow);
}
/**
* DELETE /workflows/:id
* Delete a workflow
*/
@Delete(':id')
@Middleware([requireAuth])
public async delete(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
await WorkflowService.delete(auth.projectId!, workflowId);
return res.status(204).send();
}
/**
* POST /workflows/:id/steps
* Add a step to a workflow
*/
@Post(':id/steps')
@Middleware([requireAuth])
public async addStep(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!type || !name || !position || !config) {
return res.status(400).json({error: 'Type, name, position, and config are required'});
}
const step = await WorkflowService.addStep(auth.projectId!, workflowId, {
type,
name,
position,
config,
templateId,
autoConnect,
});
return res.status(201).json(step);
}
/**
* PATCH /workflows/:id/steps/:stepId
* Update a workflow step
*/
@Patch(':id/steps/:stepId')
@Middleware([requireAuth])
public async updateStep(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
const step = await WorkflowService.updateStep(auth.projectId!, workflowId, stepId, {
name,
position,
config,
templateId,
});
return res.status(200).json(step);
}
/**
* DELETE /workflows/:id/steps/:stepId
* Delete a workflow step
*/
@Delete(':id/steps/:stepId')
@Middleware([requireAuth])
public async deleteStep(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const stepId = req.params.stepId;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
return res.status(204).send();
}
/**
* POST /workflows/:id/transitions
* Create a transition between steps
*/
@Post(':id/transitions')
@Middleware([requireAuth])
public async createTransition(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!fromStepId || !toStepId) {
return res.status(400).json({error: 'From step ID and to step ID are required'});
}
const transition = await WorkflowService.createTransition(auth.projectId!, workflowId, {
fromStepId,
toStepId,
condition,
priority,
});
return res.status(201).json(transition);
}
/**
* DELETE /workflows/:id/transitions/:transitionId
* Delete a transition
*/
@Delete(':id/transitions/:transitionId')
@Middleware([requireAuth])
public async deleteTransition(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const transitionId = req.params.transitionId;
if (!workflowId || !transitionId) {
return res.status(400).json({error: 'Workflow ID and Transition ID are required'});
}
await WorkflowService.deleteTransition(auth.projectId!, workflowId, transitionId);
return res.status(204).send();
}
/**
* POST /workflows/:id/executions
* Start a workflow execution for a contact
*/
@Post(':id/executions')
@Middleware([requireAuth])
public async startExecution(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {contactId, context} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const execution = await WorkflowService.startExecution(auth.projectId!, workflowId, contactId, context);
return res.status(201).json(execution);
}
/**
* GET /workflows/:id/executions
* List executions for a workflow
*/
@Get(':id/executions')
@Middleware([requireAuth])
public async listExecutions(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
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);
const status = req.query.status as WorkflowExecutionStatus | undefined;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.listExecutions(auth.projectId!, workflowId, page, pageSize, status);
return res.status(200).json(result);
}
/**
* GET /workflows/:id/executions/:executionId
* Get a specific execution with details
*/
@Get(':id/executions/:executionId')
@Middleware([requireAuth])
public async getExecution(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.getExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
/**
* DELETE /workflows/:id/executions/:executionId
* Cancel a workflow execution
*/
@Delete(':id/executions/:executionId')
@Middleware([requireAuth])
public async cancelExecution(req: Request, res: Response) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.cancelExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
}