Initial push of Plunk Next
This commit is contained in:
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user