Initial push of Plunk Next
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
import {STATUS_CODES} from 'node:http';
|
||||
|
||||
import {Server} from '@overnightjs/core';
|
||||
import cookies from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {json, raw} from 'express';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import signale from 'signale';
|
||||
import {ZodError} from 'zod';
|
||||
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
LANDING_URI,
|
||||
NODE_ENV,
|
||||
PORT,
|
||||
STRIPE_ENABLED,
|
||||
WIKI_URI,
|
||||
S3_ENABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
SMTP_ENABLED,
|
||||
} from './app/constants.js';
|
||||
import {Actions} from './controllers/Actions.js';
|
||||
import {Activity} from './controllers/Activity.js';
|
||||
import {Analytics} from './controllers/Analytics.js';
|
||||
import {Auth} from './controllers/Auth.js';
|
||||
import {Campaigns} from './controllers/Campaigns.js';
|
||||
import {Contacts} from './controllers/Contacts.js';
|
||||
import {Domains} from './controllers/Domains.js';
|
||||
import {Events} from './controllers/Events.js';
|
||||
import {Oauth} from './controllers/Oauth/index.js';
|
||||
import {Projects} from './controllers/Projects.js';
|
||||
import {Segments} from './controllers/Segments.js';
|
||||
import {Templates} from './controllers/Templates.js';
|
||||
import {Uploads} from './controllers/Uploads.js';
|
||||
import {Users} from './controllers/Users.js';
|
||||
import {Webhooks} from './controllers/Webhooks.js';
|
||||
import {Workflows} from './controllers/Workflows.js';
|
||||
import {Config} from './controllers/Config.js';
|
||||
import {prisma} from './database/prisma.js';
|
||||
import {ErrorCode, HttpException, type FieldError, ValidationError} from './exceptions/index.js';
|
||||
import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js';
|
||||
import * as S3Service from './services/S3Service.js';
|
||||
import {requestIdMiddleware} from './middleware/requestId.js';
|
||||
import {databaseRequestLogger} from './middleware/requestLogger.js';
|
||||
import {logger, requestLogger} from './utils/logger.js';
|
||||
|
||||
const server = new (class extends Server {
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
// Specify that we need raw json for the webhook
|
||||
this.app.use('/webhooks/incoming/stripe', raw({type: 'application/json'}));
|
||||
|
||||
// Set the content-type to JSON for any request coming from AWS SNS
|
||||
this.app.use(function (req, res, next) {
|
||||
if (req.get('x-amz-sns-message-type')) {
|
||||
req.headers['content-type'] = 'application/json';
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Parse the rest of our application as json
|
||||
this.app.use(json({limit: '50mb'}));
|
||||
this.app.use(cookies());
|
||||
this.app.use(helmet());
|
||||
|
||||
// Add request ID to all requests for tracking
|
||||
this.app.use(requestIdMiddleware);
|
||||
|
||||
// Log all requests and responses with request ID (console/file logs)
|
||||
this.app.use(requestLogger);
|
||||
|
||||
// Log all requests to database for historical tracking and analytics
|
||||
this.app.use(databaseRequestLogger);
|
||||
|
||||
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
|
||||
res.set({'Access-Control-Allow-Origin': '*'});
|
||||
next();
|
||||
});
|
||||
|
||||
// Build allowed origins from environment variables
|
||||
const allowedOrigins =
|
||||
NODE_ENV === 'development'
|
||||
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
||||
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
||||
|
||||
this.app.use(
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
||||
|
||||
this.addControllers([
|
||||
new Actions(),
|
||||
new Activity(),
|
||||
new Analytics(),
|
||||
new Auth(),
|
||||
new Campaigns(),
|
||||
new Oauth(),
|
||||
new Users(),
|
||||
new Contacts(),
|
||||
new Domains(),
|
||||
new Projects(),
|
||||
new Segments(),
|
||||
new Templates(),
|
||||
new Uploads(),
|
||||
new Webhooks(),
|
||||
new Workflows(),
|
||||
new Events(),
|
||||
new Config(),
|
||||
]);
|
||||
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
time: Date.now(),
|
||||
environment: NODE_ENV,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
});
|
||||
});
|
||||
|
||||
this.app.get('/', (_, res) => res.redirect(LANDING_URI));
|
||||
|
||||
this.app.use('*', () => {
|
||||
throw new HttpException(404, 'Unknown route');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Standardized error response format
|
||||
*/
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string; // Machine-readable error code (e.g., "VALIDATION_ERROR")
|
||||
message: string; // Human-readable error message
|
||||
statusCode: number; // HTTP status code
|
||||
requestId?: string; // Request ID for tracking
|
||||
errors?: FieldError[]; // Field-level validation errors
|
||||
details?: Record<string, unknown>; // Additional error context
|
||||
suggestion?: string; // Helpful suggestion for fixing the error
|
||||
};
|
||||
timestamp: string; // ISO timestamp
|
||||
}
|
||||
|
||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const requestId = res.locals.requestId as string | undefined;
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
received: err.code !== 'invalid_type' ? undefined : (err as any).received,
|
||||
}));
|
||||
|
||||
const statusCode = 422;
|
||||
|
||||
// Create helpful suggestions based on common validation errors
|
||||
let suggestion = 'Please check the API documentation for the correct request format.';
|
||||
if (fieldErrors.some(e => e.code === 'invalid_type')) {
|
||||
suggestion =
|
||||
'One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false.';
|
||||
} else if (fieldErrors.some(e => e.message.includes('required'))) {
|
||||
suggestion = 'Required fields are missing. Ensure all required fields are included in your request.';
|
||||
}
|
||||
|
||||
// Log validation errors with structured logging
|
||||
logger.warn(
|
||||
'Validation failed',
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
fieldCount: fieldErrors.length,
|
||||
fields: fieldErrors.map(e => e.field),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: 'Request validation failed',
|
||||
statusCode,
|
||||
requestId,
|
||||
errors: fieldErrors,
|
||||
suggestion,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle custom HTTP exceptions
|
||||
if (error instanceof HttpException) {
|
||||
const statusCode = error.code;
|
||||
const errorCode = error.errorCode || ErrorCode.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Extract validation errors if this is a ValidationError
|
||||
const validationError = error instanceof ValidationError ? error : null;
|
||||
|
||||
// Log based on severity with structured logging
|
||||
if (statusCode >= 500) {
|
||||
logger.error(
|
||||
error.message,
|
||||
error,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
errorCode,
|
||||
statusCode,
|
||||
},
|
||||
res,
|
||||
);
|
||||
} else if (statusCode >= 400) {
|
||||
logger.warn(
|
||||
error.message,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
errorCode,
|
||||
statusCode,
|
||||
},
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate helpful suggestions based on error type
|
||||
let suggestion: string | undefined;
|
||||
switch (errorCode) {
|
||||
case ErrorCode.INVALID_API_KEY:
|
||||
suggestion = 'Verify your API key is correct and starts with "sk_" for secret keys or "pk_" for public keys.';
|
||||
break;
|
||||
case ErrorCode.MISSING_AUTH:
|
||||
suggestion = 'Include an Authorization header with format: "Authorization: Bearer YOUR_API_KEY"';
|
||||
break;
|
||||
case ErrorCode.FORBIDDEN:
|
||||
suggestion =
|
||||
'This action requires additional permissions. Check that you have access to this resource or contact support.';
|
||||
break;
|
||||
case ErrorCode.TEMPLATE_NOT_FOUND:
|
||||
suggestion =
|
||||
'Ensure the template ID is correct and belongs to your project. You can list available templates via the API.';
|
||||
break;
|
||||
case ErrorCode.VALIDATION_ERROR:
|
||||
suggestion = 'Check the "errors" array for specific field-level validation issues.';
|
||||
break;
|
||||
case ErrorCode.RATE_LIMIT_EXCEEDED:
|
||||
suggestion = 'You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan.';
|
||||
break;
|
||||
case ErrorCode.UPGRADE_REQUIRED:
|
||||
suggestion = 'This feature requires a higher plan. Visit your dashboard to upgrade.';
|
||||
break;
|
||||
case ErrorCode.PROJECT_DISABLED:
|
||||
suggestion = 'Your project has been disabled. Contact support for assistance.';
|
||||
break;
|
||||
}
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: error.message,
|
||||
statusCode,
|
||||
requestId,
|
||||
errors: validationError?.errors,
|
||||
details: error.details,
|
||||
suggestion,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle unexpected errors (not HttpException)
|
||||
const statusCode = 500;
|
||||
logger.error(
|
||||
'Unexpected error occurred',
|
||||
error,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
message: 'An unexpected error occurred',
|
||||
statusCode,
|
||||
requestId,
|
||||
suggestion:
|
||||
'This is an internal server error. Please contact support with the request ID if the issue persists.',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
});
|
||||
|
||||
// Global error handlers to prevent server crashes
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('Unhandled Promise Rejection:', reason);
|
||||
console.error('Promise:', promise);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
signale.error('Uncaught Exception:', error);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
void prisma.$connect().then(async () => {
|
||||
server.app.listen(PORT, '0.0.0.0', () => signale.success('[HTTPS] Ready on', PORT));
|
||||
|
||||
// Feature matrix
|
||||
const features = [
|
||||
{
|
||||
name: 'Billing (Stripe)',
|
||||
enabled: STRIPE_ENABLED,
|
||||
details: STRIPE_ENABLED ? 'Stripe billing enabled' : 'No Stripe keys configured',
|
||||
},
|
||||
{
|
||||
name: 'File uploads (S3)',
|
||||
enabled: S3_ENABLED,
|
||||
details: S3_ENABLED ? 'S3 storage enabled' : 'S3 credentials missing',
|
||||
},
|
||||
{name: 'SMTP relay', enabled: SMTP_ENABLED, details: SMTP_ENABLED ? 'SMTP server enabled' : 'SMTP disabled'},
|
||||
{
|
||||
name: 'OAuth - GitHub',
|
||||
enabled: GITHUB_OAUTH_ENABLED,
|
||||
details: GITHUB_OAUTH_ENABLED ? 'GitHub login enabled' : 'GitHub OAuth not configured',
|
||||
},
|
||||
{
|
||||
name: 'OAuth - Google',
|
||||
enabled: GOOGLE_OAUTH_ENABLED,
|
||||
details: GOOGLE_OAUTH_ENABLED ? 'Google login enabled' : 'Google OAuth not configured',
|
||||
},
|
||||
{
|
||||
name: 'Tracking toggle',
|
||||
enabled: TRACKING_TOGGLE_ENABLED,
|
||||
details: TRACKING_TOGGLE_ENABLED
|
||||
? 'Per-project tracking toggle enabled'
|
||||
: 'Always tracking or always no-tracking',
|
||||
},
|
||||
];
|
||||
|
||||
const rows = features.map(f => ({
|
||||
Feature: f.name,
|
||||
Enabled: f.enabled ? '✅' : '❌',
|
||||
}));
|
||||
|
||||
console.table(rows);
|
||||
|
||||
if (S3_ENABLED) {
|
||||
try {
|
||||
await S3Service.initializeBucket();
|
||||
} catch (error) {
|
||||
signale.error('[S3] Failed to initialize bucket:', error);
|
||||
signale.warn('[S3] File uploads may not work properly');
|
||||
}
|
||||
}
|
||||
|
||||
// Set up repeatable job for domain verification (BullMQ)
|
||||
// Run every 5 minutes to check domain verification status with AWS SES
|
||||
await domainVerificationQueue.add(
|
||||
'check-domain-verification',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '*/5 * * * *', // Every 5 minutes
|
||||
},
|
||||
jobId: 'domain-verification-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] Domain verification scheduled (BullMQ repeatable job, runs every 5 minutes)');
|
||||
|
||||
// Set up repeatable job for segment count updates (BullMQ)
|
||||
// Run every 5 minutes to compute membership changes and trigger events
|
||||
await segmentCountQueue.add(
|
||||
'update-segment-counts',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '*/5 * * * *', // Every 5 minutes
|
||||
},
|
||||
jobId: 'segment-count-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] Segment count updater scheduled (BullMQ repeatable job, runs every 5 minutes)');
|
||||
|
||||
// Set up repeatable job for API request log cleanup (BullMQ)
|
||||
// Run daily at 3 AM to clean up old request logs
|
||||
await apiRequestCleanupQueue.add(
|
||||
'cleanup-old-requests',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '0 3 * * *', // Daily at 3 AM
|
||||
},
|
||||
jobId: 'api-request-cleanup-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] API request cleanup scheduled (BullMQ repeatable job, runs daily at 3 AM)');
|
||||
});
|
||||
Reference in New Issue
Block a user