Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
26
marketing-agent/services/template-service/package.json
Normal file
26
marketing-agent/services/template-service/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "template-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Message template management service",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"mongoose": "^7.6.3",
|
||||
"joi": "^17.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"marked": "^9.1.2",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"redis": "^4.6.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
port: process.env.PORT || 3010,
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/template-service'
|
||||
},
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || null
|
||||
},
|
||||
template: {
|
||||
maxVariables: parseInt(process.env.MAX_TEMPLATE_VARIABLES || '50'),
|
||||
maxLength: parseInt(process.env.MAX_TEMPLATE_LENGTH || '10000'),
|
||||
supportedFormats: ['text', 'html', 'markdown'],
|
||||
supportedLanguages: ['en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'zh', 'ja', 'ko']
|
||||
},
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info'
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-jwt-secret-key'
|
||||
}
|
||||
};
|
||||
65
marketing-agent/services/template-service/src/index.js
Normal file
65
marketing-agent/services/template-service/src/index.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mongoose from 'mongoose';
|
||||
import config from './config/index.js';
|
||||
import templateRoutes from './routes/templates.js';
|
||||
import categoryRoutes from './routes/categories.js';
|
||||
import variableRoutes from './routes/variables.js';
|
||||
import errorHandler from './middleware/errorHandler.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path}`, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/v1/templates', templateRoutes);
|
||||
app.use('/api/v1/template-categories', categoryRoutes);
|
||||
app.use('/api/v1/template-variables', variableRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'template-service',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Connect to MongoDB
|
||||
mongoose.connect(config.mongodb.uri)
|
||||
.then(() => {
|
||||
logger.info('Connected to MongoDB');
|
||||
|
||||
// Start server
|
||||
const PORT = config.port;
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Template service running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
logger.error('MongoDB connection error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
await mongoose.connection.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../config/index.js';
|
||||
|
||||
export const authenticate = (req, res, next) => {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export default function errorHandler(err, req, res, next) {
|
||||
logger.error('Error:', err);
|
||||
|
||||
// Mongoose validation error
|
||||
if (err.name === 'ValidationError') {
|
||||
const errors = Object.values(err.errors).map(e => e.message);
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors
|
||||
});
|
||||
}
|
||||
|
||||
// Mongoose duplicate key error
|
||||
if (err.code === 11000) {
|
||||
const field = Object.keys(err.keyPattern)[0];
|
||||
return res.status(409).json({
|
||||
error: `Duplicate value for field: ${field}`
|
||||
});
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid token'
|
||||
});
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
error: 'Token expired'
|
||||
});
|
||||
}
|
||||
|
||||
// Default error
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error',
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function validateRequest(schema, property = 'body') {
|
||||
return (req, res, next) => {
|
||||
const { error } = schema.validate(req[property], { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
const errors = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message
|
||||
}));
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
305
marketing-agent/services/template-service/src/models/Template.js
Normal file
305
marketing-agent/services/template-service/src/models/Template.js
Normal file
@@ -0,0 +1,305 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const variableSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
match: /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
||||
},
|
||||
description: String,
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['string', 'number', 'date', 'boolean', 'url', 'email', 'phone'],
|
||||
default: 'string'
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultValue: mongoose.Schema.Types.Mixed,
|
||||
validation: {
|
||||
pattern: String,
|
||||
min: Number,
|
||||
max: Number,
|
||||
enum: [mongoose.Schema.Types.Mixed]
|
||||
}
|
||||
});
|
||||
|
||||
const templateSchema = new mongoose.Schema({
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
category: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'TemplateCategory',
|
||||
index: true
|
||||
},
|
||||
format: {
|
||||
type: String,
|
||||
enum: ['text', 'html', 'markdown'],
|
||||
default: 'text'
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'en',
|
||||
lowercase: true,
|
||||
index: true
|
||||
},
|
||||
translations: [{
|
||||
language: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
translatedAt: Date,
|
||||
translatedBy: String
|
||||
}],
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
maxlength: 10000
|
||||
},
|
||||
compiledContent: {
|
||||
type: String
|
||||
},
|
||||
variables: [variableSchema],
|
||||
tags: [{
|
||||
type: String,
|
||||
lowercase: true,
|
||||
trim: true
|
||||
}],
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isGlobal: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
usageCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastUsedAt: Date,
|
||||
metadata: {
|
||||
type: Map,
|
||||
of: mongoose.Schema.Types.Mixed
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
parentTemplate: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Template'
|
||||
},
|
||||
previewData: {
|
||||
type: Map,
|
||||
of: mongoose.Schema.Types.Mixed
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
templateSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
|
||||
templateSchema.index({ tags: 1 });
|
||||
templateSchema.index({ category: 1, isActive: 1 });
|
||||
templateSchema.index({ createdAt: -1 });
|
||||
templateSchema.index({ usageCount: -1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
templateSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
|
||||
templateSchema.index({ tenantId: 1, tags: 1 });
|
||||
templateSchema.index({ tenantId: 1, category: 1, isActive: 1 });
|
||||
templateSchema.index({ tenantId: 1, createdAt: -1 });
|
||||
templateSchema.index({ tenantId: 1, usageCount: -1 });
|
||||
|
||||
// Virtual for variable names
|
||||
templateSchema.virtual('variableNames').get(function() {
|
||||
return this.variables.map(v => v.name);
|
||||
});
|
||||
|
||||
// Methods
|
||||
templateSchema.methods.extractVariables = function() {
|
||||
const regex = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
|
||||
const variables = new Set();
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(this.content)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
|
||||
return Array.from(variables);
|
||||
};
|
||||
|
||||
templateSchema.methods.validateVariables = function(data) {
|
||||
const errors = [];
|
||||
|
||||
for (const variable of this.variables) {
|
||||
const value = data[variable.name];
|
||||
|
||||
// Check required
|
||||
if (variable.required && (value === undefined || value === null || value === '')) {
|
||||
errors.push(`Variable '${variable.name}' is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip validation if not provided and not required
|
||||
if (!variable.required && (value === undefined || value === null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type validation
|
||||
switch (variable.type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' && isNaN(Number(value))) {
|
||||
errors.push(`Variable '${variable.name}' must be a number`);
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push(`Variable '${variable.name}' must be a boolean`);
|
||||
}
|
||||
break;
|
||||
case 'date':
|
||||
if (!(value instanceof Date) && isNaN(Date.parse(value))) {
|
||||
errors.push(`Variable '${variable.name}' must be a valid date`);
|
||||
}
|
||||
break;
|
||||
case 'email':
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
errors.push(`Variable '${variable.name}' must be a valid email`);
|
||||
}
|
||||
break;
|
||||
case 'phone':
|
||||
if (!/^\+?[0-9\s\-()]+$/.test(value)) {
|
||||
errors.push(`Variable '${variable.name}' must be a valid phone number`);
|
||||
}
|
||||
break;
|
||||
case 'url':
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
errors.push(`Variable '${variable.name}' must be a valid URL`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (variable.validation) {
|
||||
if (variable.validation.pattern) {
|
||||
const regex = new RegExp(variable.validation.pattern);
|
||||
if (!regex.test(value)) {
|
||||
errors.push(`Variable '${variable.name}' does not match required pattern`);
|
||||
}
|
||||
}
|
||||
|
||||
if (variable.validation.min !== undefined && value < variable.validation.min) {
|
||||
errors.push(`Variable '${variable.name}' must be at least ${variable.validation.min}`);
|
||||
}
|
||||
|
||||
if (variable.validation.max !== undefined && value > variable.validation.max) {
|
||||
errors.push(`Variable '${variable.name}' must be at most ${variable.validation.max}`);
|
||||
}
|
||||
|
||||
if (variable.validation.enum && !variable.validation.enum.includes(value)) {
|
||||
errors.push(`Variable '${variable.name}' must be one of: ${variable.validation.enum.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
templateSchema.methods.incrementUsage = async function() {
|
||||
this.usageCount += 1;
|
||||
this.lastUsedAt = new Date();
|
||||
await this.save();
|
||||
};
|
||||
|
||||
templateSchema.methods.getContent = function(language) {
|
||||
// If requesting the primary language, return main content
|
||||
if (language === this.language) {
|
||||
return this.content;
|
||||
}
|
||||
|
||||
// Look for translation
|
||||
const translation = this.translations.find(t =>
|
||||
t.language === language && t.isActive
|
||||
);
|
||||
|
||||
return translation ? translation.content : this.content;
|
||||
};
|
||||
|
||||
templateSchema.methods.addTranslation = async function(language, content, translatedBy = 'system') {
|
||||
// Remove existing translation for this language
|
||||
this.translations = this.translations.filter(t => t.language !== language);
|
||||
|
||||
// Add new translation
|
||||
this.translations.push({
|
||||
language,
|
||||
content,
|
||||
isActive: true,
|
||||
translatedAt: new Date(),
|
||||
translatedBy
|
||||
});
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// Hooks
|
||||
templateSchema.pre('save', function(next) {
|
||||
// Auto-detect variables if not manually specified
|
||||
if (!this.isModified('content')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const detectedVars = this.extractVariables();
|
||||
const existingVarNames = this.variables.map(v => v.name);
|
||||
|
||||
// Add newly detected variables
|
||||
for (const varName of detectedVars) {
|
||||
if (!existingVarNames.includes(varName)) {
|
||||
this.variables.push({
|
||||
name: varName,
|
||||
type: 'string',
|
||||
required: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove variables that are no longer in the template
|
||||
this.variables = this.variables.filter(v =>
|
||||
detectedVars.includes(v.name)
|
||||
);
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
export default mongoose.model('Template', templateSchema);
|
||||
@@ -0,0 +1,143 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const templateCategorySchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'folder'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#409EFF',
|
||||
match: /^#[0-9A-F]{6}$/i
|
||||
},
|
||||
parentCategory: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'TemplateCategory'
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isSystem: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
metadata: {
|
||||
type: Map,
|
||||
of: mongoose.Schema.Types.Mixed
|
||||
}
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
templateCategorySchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
|
||||
templateCategorySchema.index({ parentCategory: 1 });
|
||||
templateCategorySchema.index({ order: 1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
templateCategorySchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
|
||||
templateCategorySchema.index({ tenantId: 1, parentCategory: 1 });
|
||||
templateCategorySchema.index({ tenantId: 1, order: 1 });
|
||||
|
||||
// Virtual for template count
|
||||
templateCategorySchema.virtual('templateCount', {
|
||||
ref: 'Template',
|
||||
localField: '_id',
|
||||
foreignField: 'category',
|
||||
count: true
|
||||
});
|
||||
|
||||
// Methods
|
||||
templateCategorySchema.methods.getSubcategories = async function() {
|
||||
return await this.model('TemplateCategory').find({
|
||||
parentCategory: this._id
|
||||
}).sort('order');
|
||||
};
|
||||
|
||||
// Static methods
|
||||
templateCategorySchema.statics.createDefaultCategories = async function(accountId) {
|
||||
const defaultCategories = [
|
||||
{
|
||||
name: 'Welcome Messages',
|
||||
description: 'Templates for greeting new contacts',
|
||||
icon: 'message',
|
||||
color: '#67C23A',
|
||||
isSystem: true
|
||||
},
|
||||
{
|
||||
name: 'Promotional',
|
||||
description: 'Sales and promotional message templates',
|
||||
icon: 'shopping-cart',
|
||||
color: '#E6A23C',
|
||||
isSystem: true
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
description: 'System notifications and alerts',
|
||||
icon: 'bell',
|
||||
color: '#409EFF',
|
||||
isSystem: true
|
||||
},
|
||||
{
|
||||
name: 'Follow-up',
|
||||
description: 'Follow-up and reminder messages',
|
||||
icon: 'clock',
|
||||
color: '#909399',
|
||||
isSystem: true
|
||||
},
|
||||
{
|
||||
name: 'Surveys',
|
||||
description: 'Survey and feedback templates',
|
||||
icon: 'document',
|
||||
color: '#F56C6C',
|
||||
isSystem: true
|
||||
}
|
||||
];
|
||||
|
||||
const categories = [];
|
||||
for (const [index, categoryData] of defaultCategories.entries()) {
|
||||
const category = await this.findOneAndUpdate(
|
||||
{
|
||||
accountId,
|
||||
name: categoryData.name,
|
||||
isSystem: true
|
||||
},
|
||||
{
|
||||
...categoryData,
|
||||
accountId,
|
||||
order: index
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true
|
||||
}
|
||||
);
|
||||
categories.push(category);
|
||||
}
|
||||
|
||||
return categories;
|
||||
};
|
||||
|
||||
export default mongoose.model('TemplateCategory', templateCategorySchema);
|
||||
@@ -0,0 +1,363 @@
|
||||
import express from 'express';
|
||||
import TemplateCategory from '../models/TemplateCategory.js';
|
||||
import Template from '../models/Template.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const createCategorySchema = Joi.object({
|
||||
name: Joi.string().required().min(1).max(50),
|
||||
description: Joi.string().optional().max(200),
|
||||
icon: Joi.string().optional().default('folder'),
|
||||
color: Joi.string().optional().pattern(/^#[0-9A-F]{6}$/i).default('#409EFF'),
|
||||
parentCategory: Joi.string().optional(),
|
||||
order: Joi.number().optional().default(0),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const updateCategorySchema = Joi.object({
|
||||
name: Joi.string().min(1).max(50).optional(),
|
||||
description: Joi.string().max(200).optional(),
|
||||
icon: Joi.string().optional(),
|
||||
color: Joi.string().pattern(/^#[0-9A-F]{6}$/i).optional(),
|
||||
parentCategory: Joi.string().optional(),
|
||||
order: Joi.number().optional(),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
// Initialize default categories
|
||||
router.post('/initialize', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const categories = await TemplateCategory.createDefaultCategories(req.user.accountId);
|
||||
|
||||
res.json({
|
||||
message: 'Default categories created',
|
||||
categories: categories.map(cat => ({
|
||||
id: cat._id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
color: cat.color,
|
||||
isSystem: cat.isSystem
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create category
|
||||
router.post('/', authenticate, validateRequest(createCategorySchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if category name already exists
|
||||
const existingCategory = await TemplateCategory.findOne({
|
||||
accountId: req.user.accountId,
|
||||
name: req.body.name
|
||||
});
|
||||
|
||||
if (existingCategory) {
|
||||
return res.status(409).json({ error: 'Category with this name already exists' });
|
||||
}
|
||||
|
||||
const category = new TemplateCategory({
|
||||
accountId: req.user.accountId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
await category.save();
|
||||
|
||||
res.status(201).json({
|
||||
category: {
|
||||
id: category._id,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
parentCategory: category.parentCategory,
|
||||
order: category.order,
|
||||
isSystem: category.isSystem,
|
||||
createdAt: category.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List categories
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { includeCount = false } = req.query;
|
||||
|
||||
let query = TemplateCategory.find({ accountId: req.user.accountId })
|
||||
.sort('order name');
|
||||
|
||||
if (includeCount === 'true') {
|
||||
query = query.populate('templateCount');
|
||||
}
|
||||
|
||||
const categories = await query;
|
||||
|
||||
// Build hierarchical structure if needed
|
||||
const categoryMap = new Map();
|
||||
const rootCategories = [];
|
||||
|
||||
categories.forEach(cat => {
|
||||
categoryMap.set(cat._id.toString(), {
|
||||
id: cat._id,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
icon: cat.icon,
|
||||
color: cat.color,
|
||||
parentCategory: cat.parentCategory,
|
||||
order: cat.order,
|
||||
isSystem: cat.isSystem,
|
||||
templateCount: cat.templateCount || 0,
|
||||
subcategories: []
|
||||
});
|
||||
});
|
||||
|
||||
categoryMap.forEach(cat => {
|
||||
if (cat.parentCategory) {
|
||||
const parent = categoryMap.get(cat.parentCategory.toString());
|
||||
if (parent) {
|
||||
parent.subcategories.push(cat);
|
||||
}
|
||||
} else {
|
||||
rootCategories.push(cat);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ categories: rootCategories });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get category details
|
||||
router.get('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const category = await TemplateCategory.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
// Get template count
|
||||
const templateCount = await Template.countDocuments({
|
||||
accountId: req.user.accountId,
|
||||
category: category._id
|
||||
});
|
||||
|
||||
// Get subcategories
|
||||
const subcategories = await category.getSubcategories();
|
||||
|
||||
res.json({
|
||||
category: {
|
||||
id: category._id,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
parentCategory: category.parentCategory,
|
||||
order: category.order,
|
||||
isSystem: category.isSystem,
|
||||
templateCount,
|
||||
subcategories: subcategories.map(sub => ({
|
||||
id: sub._id,
|
||||
name: sub.name,
|
||||
icon: sub.icon,
|
||||
color: sub.color
|
||||
})),
|
||||
createdAt: category.createdAt,
|
||||
updatedAt: category.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update category
|
||||
router.put('/:id', authenticate, validateRequest(updateCategorySchema), async (req, res, next) => {
|
||||
try {
|
||||
const category = await TemplateCategory.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
if (category.isSystem) {
|
||||
return res.status(403).json({ error: 'Cannot modify system categories' });
|
||||
}
|
||||
|
||||
// Check if new name conflicts
|
||||
if (req.body.name && req.body.name !== category.name) {
|
||||
const existingCategory = await TemplateCategory.findOne({
|
||||
accountId: req.user.accountId,
|
||||
name: req.body.name,
|
||||
_id: { $ne: category._id }
|
||||
});
|
||||
|
||||
if (existingCategory) {
|
||||
return res.status(409).json({ error: 'Category with this name already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent circular parent reference
|
||||
if (req.body.parentCategory) {
|
||||
if (req.body.parentCategory === category._id.toString()) {
|
||||
return res.status(400).json({ error: 'Category cannot be its own parent' });
|
||||
}
|
||||
|
||||
// Check if the new parent is a descendant
|
||||
const isDescendant = async (catId, targetId) => {
|
||||
const cat = await TemplateCategory.findById(catId);
|
||||
if (!cat) return false;
|
||||
if (cat.parentCategory?.toString() === targetId) return true;
|
||||
if (cat.parentCategory) {
|
||||
return isDescendant(cat.parentCategory, targetId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await isDescendant(req.body.parentCategory, category._id.toString())) {
|
||||
return res.status(400).json({ error: 'Cannot set a descendant as parent' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.keys(req.body).forEach(key => {
|
||||
category[key] = req.body[key];
|
||||
});
|
||||
|
||||
await category.save();
|
||||
|
||||
res.json({
|
||||
category: {
|
||||
id: category._id,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
icon: category.icon,
|
||||
color: category.color,
|
||||
parentCategory: category.parentCategory,
|
||||
order: category.order,
|
||||
updatedAt: category.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete category
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const category = await TemplateCategory.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
if (category.isSystem) {
|
||||
return res.status(403).json({ error: 'Cannot delete system categories' });
|
||||
}
|
||||
|
||||
// Check if category has templates
|
||||
const templateCount = await Template.countDocuments({
|
||||
accountId: req.user.accountId,
|
||||
category: category._id
|
||||
});
|
||||
|
||||
if (templateCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete category with templates',
|
||||
templateCount
|
||||
});
|
||||
}
|
||||
|
||||
// Check if category has subcategories
|
||||
const subcategoryCount = await TemplateCategory.countDocuments({
|
||||
accountId: req.user.accountId,
|
||||
parentCategory: category._id
|
||||
});
|
||||
|
||||
if (subcategoryCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete category with subcategories',
|
||||
subcategoryCount
|
||||
});
|
||||
}
|
||||
|
||||
await category.deleteOne();
|
||||
|
||||
res.json({ message: 'Category deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get templates in category
|
||||
router.get('/:id/templates', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const category = await TemplateCategory.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Category not found' });
|
||||
}
|
||||
|
||||
const [templates, total] = await Promise.all([
|
||||
Template.find({
|
||||
accountId: req.user.accountId,
|
||||
category: category._id
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit)),
|
||||
Template.countDocuments({
|
||||
accountId: req.user.accountId,
|
||||
category: category._id
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
templates: templates.map(template => ({
|
||||
id: template._id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
format: template.format,
|
||||
isActive: template.isActive,
|
||||
usageCount: template.usageCount,
|
||||
lastUsedAt: template.lastUsedAt
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,610 @@
|
||||
import express from 'express';
|
||||
import Template from '../models/Template.js';
|
||||
import TemplateCategory from '../models/TemplateCategory.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { templateEngine } from '../services/templateEngine.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const createTemplateSchema = Joi.object({
|
||||
name: Joi.string().required().min(1).max(100),
|
||||
description: Joi.string().optional().max(500),
|
||||
category: Joi.string().optional(),
|
||||
format: Joi.string().valid('text', 'html', 'markdown').default('text'),
|
||||
language: Joi.string().default('en'),
|
||||
content: Joi.string().required().max(10000),
|
||||
variables: Joi.array().items(Joi.object({
|
||||
name: Joi.string().required().pattern(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
|
||||
description: Joi.string().optional(),
|
||||
type: Joi.string().valid('string', 'number', 'date', 'boolean', 'url', 'email', 'phone').default('string'),
|
||||
required: Joi.boolean().default(false),
|
||||
defaultValue: Joi.any().optional(),
|
||||
validation: Joi.object({
|
||||
pattern: Joi.string().optional(),
|
||||
min: Joi.number().optional(),
|
||||
max: Joi.number().optional(),
|
||||
enum: Joi.array().optional()
|
||||
}).optional()
|
||||
})).optional(),
|
||||
tags: Joi.array().items(Joi.string()).optional(),
|
||||
isActive: Joi.boolean().default(true),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const updateTemplateSchema = Joi.object({
|
||||
name: Joi.string().min(1).max(100).optional(),
|
||||
description: Joi.string().max(500).optional(),
|
||||
category: Joi.string().optional(),
|
||||
format: Joi.string().valid('text', 'html', 'markdown').optional(),
|
||||
language: Joi.string().optional(),
|
||||
content: Joi.string().max(10000).optional(),
|
||||
variables: Joi.array().items(Joi.object({
|
||||
name: Joi.string().required().pattern(/^[a-zA-Z_][a-zA-Z0-9_]*$/),
|
||||
description: Joi.string().optional(),
|
||||
type: Joi.string().valid('string', 'number', 'date', 'boolean', 'url', 'email', 'phone').default('string'),
|
||||
required: Joi.boolean().default(false),
|
||||
defaultValue: Joi.any().optional(),
|
||||
validation: Joi.object({
|
||||
pattern: Joi.string().optional(),
|
||||
min: Joi.number().optional(),
|
||||
max: Joi.number().optional(),
|
||||
enum: Joi.array().optional()
|
||||
}).optional()
|
||||
})).optional(),
|
||||
tags: Joi.array().items(Joi.string()).optional(),
|
||||
isActive: Joi.boolean().optional(),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const renderTemplateSchema = Joi.object({
|
||||
templateId: Joi.string().required(),
|
||||
data: Joi.object().required(),
|
||||
preview: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
const previewTemplateSchema = Joi.object({
|
||||
content: Joi.string().required(),
|
||||
format: Joi.string().valid('text', 'html', 'markdown').default('text'),
|
||||
data: Joi.object().optional()
|
||||
});
|
||||
|
||||
// Create template
|
||||
router.post('/', authenticate, validateRequest(createTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if template name already exists
|
||||
const existingTemplate = await Template.findOne({
|
||||
accountId: req.user.accountId,
|
||||
name: req.body.name
|
||||
});
|
||||
|
||||
if (existingTemplate) {
|
||||
return res.status(409).json({ error: 'Template with this name already exists' });
|
||||
}
|
||||
|
||||
const template = new Template({
|
||||
accountId: req.user.accountId,
|
||||
...req.body
|
||||
});
|
||||
|
||||
// Validate syntax
|
||||
const syntaxValidation = templateEngine.validateSyntax(template.content);
|
||||
if (!syntaxValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid template syntax',
|
||||
details: syntaxValidation.errors
|
||||
});
|
||||
}
|
||||
|
||||
await template.save();
|
||||
|
||||
res.status(201).json({
|
||||
template: {
|
||||
id: template._id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
format: template.format,
|
||||
language: template.language,
|
||||
content: template.content,
|
||||
variables: template.variables,
|
||||
tags: template.tags,
|
||||
isActive: template.isActive,
|
||||
usageCount: template.usageCount,
|
||||
createdAt: template.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// List templates
|
||||
router.get('/', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
category,
|
||||
tags,
|
||||
format,
|
||||
language,
|
||||
isActive,
|
||||
search
|
||||
} = req.query;
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const query = { accountId: req.user.accountId };
|
||||
|
||||
if (category) {
|
||||
query.category = category;
|
||||
}
|
||||
|
||||
if (tags) {
|
||||
const tagArray = Array.isArray(tags) ? tags : tags.split(',');
|
||||
query.tags = { $in: tagArray };
|
||||
}
|
||||
|
||||
if (format) {
|
||||
query.format = format;
|
||||
}
|
||||
|
||||
if (language) {
|
||||
query.language = language;
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
query.isActive = isActive === 'true';
|
||||
}
|
||||
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ name: { $regex: search, $options: 'i' } },
|
||||
{ description: { $regex: search, $options: 'i' } },
|
||||
{ content: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [templates, total] = await Promise.all([
|
||||
Template.find(query)
|
||||
.populate('category', 'name color icon')
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit)),
|
||||
Template.countDocuments(query)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
templates: templates.map(template => ({
|
||||
id: template._id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
format: template.format,
|
||||
language: template.language,
|
||||
variables: template.variables,
|
||||
tags: template.tags,
|
||||
isActive: template.isActive,
|
||||
usageCount: template.usageCount,
|
||||
lastUsedAt: template.lastUsedAt,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt
|
||||
})),
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get template details
|
||||
router.get('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const template = await Template.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
}).populate('category', 'name color icon');
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
template: {
|
||||
id: template._id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
format: template.format,
|
||||
language: template.language,
|
||||
content: template.content,
|
||||
variables: template.variables,
|
||||
tags: template.tags,
|
||||
isActive: template.isActive,
|
||||
usageCount: template.usageCount,
|
||||
lastUsedAt: template.lastUsedAt,
|
||||
metadata: template.metadata,
|
||||
version: template.version,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Update template
|
||||
router.put('/:id', authenticate, validateRequest(updateTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
const template = await Template.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Check if new name conflicts
|
||||
if (req.body.name && req.body.name !== template.name) {
|
||||
const existingTemplate = await Template.findOne({
|
||||
accountId: req.user.accountId,
|
||||
name: req.body.name,
|
||||
_id: { $ne: template._id }
|
||||
});
|
||||
|
||||
if (existingTemplate) {
|
||||
return res.status(409).json({ error: 'Template with this name already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
Object.keys(req.body).forEach(key => {
|
||||
template[key] = req.body[key];
|
||||
});
|
||||
|
||||
// Validate syntax if content changed
|
||||
if (req.body.content) {
|
||||
const syntaxValidation = templateEngine.validateSyntax(template.content);
|
||||
if (!syntaxValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid template syntax',
|
||||
details: syntaxValidation.errors
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Increment version
|
||||
template.version += 1;
|
||||
|
||||
await template.save();
|
||||
|
||||
res.json({
|
||||
template: {
|
||||
id: template._id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
format: template.format,
|
||||
language: template.language,
|
||||
content: template.content,
|
||||
variables: template.variables,
|
||||
tags: template.tags,
|
||||
isActive: template.isActive,
|
||||
version: template.version,
|
||||
updatedAt: template.updatedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete template
|
||||
router.delete('/:id', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const template = await Template.findOneAndDelete({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Template deleted successfully' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Duplicate template
|
||||
router.post('/:id/duplicate', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const sourceTemplate = await Template.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!sourceTemplate) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Create new template with copy suffix
|
||||
const baseName = sourceTemplate.name.replace(/ \(Copy \d+\)$/, '');
|
||||
let copyNumber = 1;
|
||||
let newName = `${baseName} (Copy)`;
|
||||
|
||||
// Find unique name
|
||||
while (await Template.findOne({ accountId: req.user.accountId, name: newName })) {
|
||||
copyNumber++;
|
||||
newName = `${baseName} (Copy ${copyNumber})`;
|
||||
}
|
||||
|
||||
const newTemplate = new Template({
|
||||
accountId: req.user.accountId,
|
||||
name: newName,
|
||||
description: sourceTemplate.description,
|
||||
category: sourceTemplate.category,
|
||||
format: sourceTemplate.format,
|
||||
language: sourceTemplate.language,
|
||||
content: sourceTemplate.content,
|
||||
variables: sourceTemplate.variables,
|
||||
tags: sourceTemplate.tags,
|
||||
isActive: true,
|
||||
parentTemplate: sourceTemplate._id,
|
||||
metadata: sourceTemplate.metadata
|
||||
});
|
||||
|
||||
await newTemplate.save();
|
||||
|
||||
res.status(201).json({
|
||||
template: {
|
||||
id: newTemplate._id,
|
||||
name: newTemplate.name,
|
||||
description: newTemplate.description,
|
||||
category: newTemplate.category,
|
||||
format: newTemplate.format,
|
||||
language: newTemplate.language,
|
||||
content: newTemplate.content,
|
||||
variables: newTemplate.variables,
|
||||
tags: newTemplate.tags,
|
||||
createdAt: newTemplate.createdAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Render template
|
||||
router.post('/render', authenticate, validateRequest(renderTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { templateId, data, preview, language } = req.body;
|
||||
|
||||
const template = await Template.findOne({
|
||||
_id: templateId,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
if (!template.isActive && !preview) {
|
||||
return res.status(400).json({ error: 'Template is not active' });
|
||||
}
|
||||
|
||||
// Validate variables
|
||||
const validationErrors = template.validateVariables(data);
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Variable validation failed',
|
||||
details: validationErrors
|
||||
});
|
||||
}
|
||||
|
||||
// Get content in requested language
|
||||
const requestedLanguage = language || template.language || 'en';
|
||||
const content = template.getContent(requestedLanguage);
|
||||
|
||||
// Compile and render
|
||||
const compiled = templateEngine.compile(
|
||||
`${template._id}:${requestedLanguage}`,
|
||||
content,
|
||||
template.format
|
||||
);
|
||||
|
||||
const rendered = templateEngine.render(compiled, data, {
|
||||
format: template.format,
|
||||
sanitize: true
|
||||
});
|
||||
|
||||
// Update usage stats if not preview
|
||||
if (!preview) {
|
||||
await template.incrementUsage();
|
||||
}
|
||||
|
||||
res.json({
|
||||
content: rendered,
|
||||
format: template.format,
|
||||
templateId: template._id,
|
||||
templateName: template.name,
|
||||
language: requestedLanguage
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Preview template
|
||||
router.post('/preview', authenticate, validateRequest(previewTemplateSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { content, format, data } = req.body;
|
||||
|
||||
const preview = templateEngine.preview(content, format, data);
|
||||
|
||||
res.json({
|
||||
preview,
|
||||
format
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Validate template syntax
|
||||
router.post('/validate', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
|
||||
if (!content) {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
const validation = templateEngine.validateSyntax(content);
|
||||
|
||||
res.json(validation);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get popular templates
|
||||
router.get('/stats/popular', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const templates = await Template.find({
|
||||
accountId: req.user.accountId,
|
||||
usageCount: { $gt: 0 }
|
||||
})
|
||||
.sort({ usageCount: -1 })
|
||||
.limit(10)
|
||||
.select('name usageCount lastUsedAt format category');
|
||||
|
||||
res.json({
|
||||
templates: templates.map(t => ({
|
||||
id: t._id,
|
||||
name: t.name,
|
||||
usageCount: t.usageCount,
|
||||
lastUsedAt: t.lastUsedAt,
|
||||
format: t.format,
|
||||
category: t.category
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get template translations
|
||||
router.get('/:id/translations', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const template = await Template.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
translations: template.translations || [],
|
||||
primaryLanguage: template.language
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Add or update template translation
|
||||
router.post('/:id/translations', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { language, content } = req.body;
|
||||
|
||||
if (!language || !content) {
|
||||
return res.status(400).json({ error: 'Language and content are required' });
|
||||
}
|
||||
|
||||
const template = await Template.findOne({
|
||||
_id: req.params.id,
|
||||
accountId: req.user.accountId
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
await template.addTranslation(language, content, req.user.id);
|
||||
|
||||
res.json({
|
||||
message: 'Translation added successfully',
|
||||
translations: template.translations
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Render template by name and language
|
||||
router.post('/render-by-name', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { name, language, data, preview } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Template name is required' });
|
||||
}
|
||||
|
||||
const template = await Template.findOne({
|
||||
accountId: req.user.accountId,
|
||||
name: name,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Get content in requested language
|
||||
const requestedLanguage = language || template.language || 'en';
|
||||
const content = template.getContent(requestedLanguage);
|
||||
|
||||
// Compile and render
|
||||
const compiled = templateEngine.compile(
|
||||
`${template._id}:${requestedLanguage}`,
|
||||
content,
|
||||
template.format
|
||||
);
|
||||
|
||||
const rendered = templateEngine.render(compiled, data || {}, {
|
||||
format: template.format,
|
||||
sanitize: true
|
||||
});
|
||||
|
||||
// Update usage stats if not preview
|
||||
if (!preview) {
|
||||
await template.incrementUsage();
|
||||
}
|
||||
|
||||
res.json({
|
||||
content: rendered,
|
||||
format: template.format,
|
||||
templateId: template._id,
|
||||
templateName: template.name,
|
||||
language: requestedLanguage
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,214 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get available template variables
|
||||
router.get('/available', authenticate, (req, res) => {
|
||||
const availableVariables = [
|
||||
{
|
||||
category: 'Contact Information',
|
||||
variables: [
|
||||
{ name: 'firstName', type: 'string', description: 'Contact first name' },
|
||||
{ name: 'lastName', type: 'string', description: 'Contact last name' },
|
||||
{ name: 'fullName', type: 'string', description: 'Contact full name' },
|
||||
{ name: 'email', type: 'email', description: 'Contact email address' },
|
||||
{ name: 'phone', type: 'phone', description: 'Contact phone number' },
|
||||
{ name: 'company', type: 'string', description: 'Contact company name' },
|
||||
{ name: 'jobTitle', type: 'string', description: 'Contact job title' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Campaign Information',
|
||||
variables: [
|
||||
{ name: 'campaignName', type: 'string', description: 'Campaign name' },
|
||||
{ name: 'campaignUrl', type: 'url', description: 'Campaign landing page URL' },
|
||||
{ name: 'campaignCode', type: 'string', description: 'Campaign tracking code' },
|
||||
{ name: 'offerName', type: 'string', description: 'Offer or promotion name' },
|
||||
{ name: 'discountAmount', type: 'number', description: 'Discount amount' },
|
||||
{ name: 'discountCode', type: 'string', description: 'Discount code' },
|
||||
{ name: 'expiryDate', type: 'date', description: 'Offer expiry date' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'System Variables',
|
||||
variables: [
|
||||
{ name: 'currentDate', type: 'date', description: 'Current date' },
|
||||
{ name: 'currentTime', type: 'string', description: 'Current time' },
|
||||
{ name: 'unsubscribeUrl', type: 'url', description: 'Unsubscribe link' },
|
||||
{ name: 'preferencesUrl', type: 'url', description: 'Preferences update link' },
|
||||
{ name: 'accountName', type: 'string', description: 'Your account name' },
|
||||
{ name: 'senderName', type: 'string', description: 'Sender name' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Custom Fields',
|
||||
variables: [
|
||||
{ name: 'customField1', type: 'string', description: 'Custom field 1' },
|
||||
{ name: 'customField2', type: 'string', description: 'Custom field 2' },
|
||||
{ name: 'customField3', type: 'string', description: 'Custom field 3' },
|
||||
{ name: 'customField4', type: 'string', description: 'Custom field 4' },
|
||||
{ name: 'customField5', type: 'string', description: 'Custom field 5' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
res.json({ variables: availableVariables });
|
||||
});
|
||||
|
||||
// Get variable helpers
|
||||
router.get('/helpers', authenticate, (req, res) => {
|
||||
const helpers = [
|
||||
{
|
||||
category: 'Formatting',
|
||||
helpers: [
|
||||
{
|
||||
name: 'formatDate',
|
||||
syntax: '{{formatDate date "format"}}',
|
||||
description: 'Format a date',
|
||||
examples: [
|
||||
'{{formatDate currentDate "short"}}',
|
||||
'{{formatDate expiryDate "long"}}'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'formatNumber',
|
||||
syntax: '{{formatNumber number decimals}}',
|
||||
description: 'Format a number with decimals',
|
||||
examples: [
|
||||
'{{formatNumber price 2}}',
|
||||
'{{formatNumber quantity 0}}'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'formatCurrency',
|
||||
syntax: '{{formatCurrency amount "currency"}}',
|
||||
description: 'Format as currency',
|
||||
examples: [
|
||||
'{{formatCurrency price "USD"}}',
|
||||
'{{formatCurrency discountAmount "EUR"}}'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'String Manipulation',
|
||||
helpers: [
|
||||
{
|
||||
name: 'uppercase',
|
||||
syntax: '{{uppercase string}}',
|
||||
description: 'Convert to uppercase',
|
||||
examples: ['{{uppercase firstName}}']
|
||||
},
|
||||
{
|
||||
name: 'lowercase',
|
||||
syntax: '{{lowercase string}}',
|
||||
description: 'Convert to lowercase',
|
||||
examples: ['{{lowercase email}}']
|
||||
},
|
||||
{
|
||||
name: 'capitalize',
|
||||
syntax: '{{capitalize string}}',
|
||||
description: 'Capitalize first letter',
|
||||
examples: ['{{capitalize lastName}}']
|
||||
},
|
||||
{
|
||||
name: 'truncate',
|
||||
syntax: '{{truncate string length}}',
|
||||
description: 'Truncate string to length',
|
||||
examples: ['{{truncate description 100}}']
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Conditionals',
|
||||
helpers: [
|
||||
{
|
||||
name: 'if',
|
||||
syntax: '{{#if condition}}...{{/if}}',
|
||||
description: 'Conditional block',
|
||||
examples: [
|
||||
'{{#if firstName}}Hello {{firstName}}{{/if}}',
|
||||
'{{#if premium}}Special offer for you!{{/if}}'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'unless',
|
||||
syntax: '{{#unless condition}}...{{/unless}}',
|
||||
description: 'Inverse conditional',
|
||||
examples: [
|
||||
'{{#unless subscribed}}Subscribe now!{{/unless}}'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'each',
|
||||
syntax: '{{#each array}}...{{/each}}',
|
||||
description: 'Loop through array',
|
||||
examples: [
|
||||
'{{#each items}}\n- {{this}}\n{{/each}}'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Comparison',
|
||||
helpers: [
|
||||
{
|
||||
name: 'eq',
|
||||
syntax: '{{#if (eq a b)}}...{{/if}}',
|
||||
description: 'Equal comparison',
|
||||
examples: ['{{#if (eq status "active")}}Active{{/if}}']
|
||||
},
|
||||
{
|
||||
name: 'gt',
|
||||
syntax: '{{#if (gt a b)}}...{{/if}}',
|
||||
description: 'Greater than',
|
||||
examples: ['{{#if (gt score 100)}}High score!{{/if}}']
|
||||
},
|
||||
{
|
||||
name: 'lt',
|
||||
syntax: '{{#if (lt a b)}}...{{/if}}',
|
||||
description: 'Less than',
|
||||
examples: ['{{#if (lt daysLeft 7)}}Hurry!{{/if}}']
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
res.json({ helpers });
|
||||
});
|
||||
|
||||
// Get sample data for testing
|
||||
router.get('/sample-data', authenticate, (req, res) => {
|
||||
const sampleData = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
fullName: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1234567890',
|
||||
company: 'Example Corp',
|
||||
jobTitle: 'Marketing Manager',
|
||||
campaignName: 'Summer Sale 2024',
|
||||
campaignUrl: 'https://example.com/summer-sale',
|
||||
campaignCode: 'SUMMER24',
|
||||
offerName: '20% Off Everything',
|
||||
discountAmount: 20,
|
||||
discountCode: 'SAVE20',
|
||||
expiryDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||
currentDate: new Date(),
|
||||
currentTime: new Date().toLocaleTimeString(),
|
||||
unsubscribeUrl: 'https://example.com/unsubscribe',
|
||||
preferencesUrl: 'https://example.com/preferences',
|
||||
accountName: 'Your Company',
|
||||
senderName: 'Marketing Team',
|
||||
items: ['Product A', 'Product B', 'Product C'],
|
||||
premium: true,
|
||||
subscribed: true,
|
||||
score: 150,
|
||||
daysLeft: 5
|
||||
};
|
||||
|
||||
res.json({ sampleData });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,271 @@
|
||||
import Handlebars from 'handlebars';
|
||||
import { marked } from 'marked';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class TemplateEngine {
|
||||
constructor() {
|
||||
this.handlebars = Handlebars.create();
|
||||
this.setupHelpers();
|
||||
this.compiledTemplates = new Map();
|
||||
}
|
||||
|
||||
setupHelpers() {
|
||||
// Date formatting helper
|
||||
this.handlebars.registerHelper('formatDate', (date, format) => {
|
||||
if (!date) return '';
|
||||
const d = new Date(date);
|
||||
|
||||
switch (format) {
|
||||
case 'short':
|
||||
return d.toLocaleDateString();
|
||||
case 'long':
|
||||
return d.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
case 'time':
|
||||
return d.toLocaleTimeString();
|
||||
default:
|
||||
return d.toLocaleString();
|
||||
}
|
||||
});
|
||||
|
||||
// Number formatting helper
|
||||
this.handlebars.registerHelper('formatNumber', (number, decimals = 0) => {
|
||||
if (typeof number !== 'number') return '';
|
||||
return number.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
});
|
||||
|
||||
// Currency formatting helper
|
||||
this.handlebars.registerHelper('formatCurrency', (amount, currency = 'USD') => {
|
||||
if (typeof amount !== 'number') return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency
|
||||
}).format(amount);
|
||||
});
|
||||
|
||||
// Conditional helpers
|
||||
this.handlebars.registerHelper('eq', (a, b) => a === b);
|
||||
this.handlebars.registerHelper('ne', (a, b) => a !== b);
|
||||
this.handlebars.registerHelper('lt', (a, b) => a < b);
|
||||
this.handlebars.registerHelper('gt', (a, b) => a > b);
|
||||
this.handlebars.registerHelper('lte', (a, b) => a <= b);
|
||||
this.handlebars.registerHelper('gte', (a, b) => a >= b);
|
||||
this.handlebars.registerHelper('and', (a, b) => a && b);
|
||||
this.handlebars.registerHelper('or', (a, b) => a || b);
|
||||
|
||||
// String helpers
|
||||
this.handlebars.registerHelper('uppercase', (str) =>
|
||||
typeof str === 'string' ? str.toUpperCase() : ''
|
||||
);
|
||||
this.handlebars.registerHelper('lowercase', (str) =>
|
||||
typeof str === 'string' ? str.toLowerCase() : ''
|
||||
);
|
||||
this.handlebars.registerHelper('capitalize', (str) => {
|
||||
if (typeof str !== 'string') return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
});
|
||||
this.handlebars.registerHelper('truncate', (str, length = 50) => {
|
||||
if (typeof str !== 'string') return '';
|
||||
if (str.length <= length) return str;
|
||||
return str.substring(0, length) + '...';
|
||||
});
|
||||
|
||||
// Array helpers
|
||||
this.handlebars.registerHelper('join', (array, separator = ', ') => {
|
||||
if (!Array.isArray(array)) return '';
|
||||
return array.join(separator);
|
||||
});
|
||||
this.handlebars.registerHelper('first', (array) => {
|
||||
if (!Array.isArray(array) || array.length === 0) return '';
|
||||
return array[0];
|
||||
});
|
||||
this.handlebars.registerHelper('last', (array) => {
|
||||
if (!Array.isArray(array) || array.length === 0) return '';
|
||||
return array[array.length - 1];
|
||||
});
|
||||
|
||||
// URL encoding helper
|
||||
this.handlebars.registerHelper('urlencode', (str) => {
|
||||
return encodeURIComponent(str);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a template
|
||||
* @param {string} templateId - Unique identifier for caching
|
||||
* @param {string} content - Template content
|
||||
* @param {string} format - Template format (text, html, markdown)
|
||||
* @returns {Function} Compiled template function
|
||||
*/
|
||||
compile(templateId, content, format = 'text') {
|
||||
try {
|
||||
// Check cache
|
||||
const cacheKey = `${templateId}:${format}`;
|
||||
if (this.compiledTemplates.has(cacheKey)) {
|
||||
return this.compiledTemplates.get(cacheKey);
|
||||
}
|
||||
|
||||
// Pre-process based on format
|
||||
let processedContent = content;
|
||||
if (format === 'markdown') {
|
||||
// Convert markdown to HTML first
|
||||
processedContent = marked(content);
|
||||
}
|
||||
|
||||
// Compile with Handlebars
|
||||
const compiled = this.handlebars.compile(processedContent);
|
||||
|
||||
// Cache the compiled template
|
||||
this.compiledTemplates.set(cacheKey, compiled);
|
||||
|
||||
return compiled;
|
||||
} catch (error) {
|
||||
logger.error('Template compilation error:', error);
|
||||
throw new Error(`Failed to compile template: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template with data
|
||||
* @param {Function|string} template - Compiled template or template content
|
||||
* @param {object} data - Data to render
|
||||
* @param {object} options - Rendering options
|
||||
* @returns {string} Rendered content
|
||||
*/
|
||||
render(template, data = {}, options = {}) {
|
||||
try {
|
||||
const { format = 'text', sanitize = true } = options;
|
||||
|
||||
// If template is a string, compile it first
|
||||
const compiledTemplate = typeof template === 'string'
|
||||
? this.compile('temp', template, format)
|
||||
: template;
|
||||
|
||||
// Render the template
|
||||
let rendered = compiledTemplate(data);
|
||||
|
||||
// Post-process based on format
|
||||
if (format === 'html' || format === 'markdown') {
|
||||
if (sanitize) {
|
||||
rendered = this.sanitizeHtml(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return rendered;
|
||||
} catch (error) {
|
||||
logger.error('Template rendering error:', error);
|
||||
throw new Error(`Failed to render template: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML content
|
||||
* @param {string} html - HTML content to sanitize
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
sanitizeHtml(html) {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: [
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'p', 'a', 'ul', 'ol', 'li', 'blockquote',
|
||||
'b', 'i', 'strong', 'em', 'u', 'code', 'pre',
|
||||
'br', 'hr', 'div', 'span', 'img', 'table',
|
||||
'thead', 'tbody', 'tr', 'th', 'td'
|
||||
],
|
||||
allowedAttributes: {
|
||||
'a': ['href', 'title', 'target'],
|
||||
'img': ['src', 'alt', 'title', 'width', 'height'],
|
||||
'div': ['class', 'id'],
|
||||
'span': ['class', 'id'],
|
||||
'h1': ['id'],
|
||||
'h2': ['id'],
|
||||
'h3': ['id'],
|
||||
'h4': ['id'],
|
||||
'h5': ['id'],
|
||||
'h6': ['id']
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
allowedSchemesByTag: {
|
||||
img: ['http', 'https', 'data']
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a template with sample data
|
||||
* @param {string} content - Template content
|
||||
* @param {string} format - Template format
|
||||
* @param {object} sampleData - Sample data for preview
|
||||
* @returns {string} Preview content
|
||||
*/
|
||||
preview(content, format = 'text', sampleData = {}) {
|
||||
try {
|
||||
// Add some default sample data if none provided
|
||||
const defaultSampleData = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1234567890',
|
||||
company: 'Example Corp',
|
||||
date: new Date(),
|
||||
amount: 99.99,
|
||||
url: 'https://example.com',
|
||||
items: ['Item 1', 'Item 2', 'Item 3'],
|
||||
...sampleData
|
||||
};
|
||||
|
||||
return this.render(content, defaultSampleData, { format, sanitize: true });
|
||||
} catch (error) {
|
||||
return `Preview Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate template syntax
|
||||
* @param {string} content - Template content
|
||||
* @returns {object} Validation result
|
||||
*/
|
||||
validateSyntax(content) {
|
||||
try {
|
||||
this.handlebars.compile(content);
|
||||
return {
|
||||
valid: true,
|
||||
errors: []
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
message: error.message,
|
||||
line: error.lineNumber || null,
|
||||
column: error.column || null
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear template cache
|
||||
*/
|
||||
clearCache() {
|
||||
this.compiledTemplates.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template statistics
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
cachedTemplates: this.compiledTemplates.size,
|
||||
helpers: Object.keys(this.handlebars.helpers).length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const templateEngine = new TemplateEngine();
|
||||
@@ -0,0 +1,33 @@
|
||||
import winston from 'winston';
|
||||
import config from '../config/index.js';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: config.logging.level,
|
||||
format: logFormat,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Add file transport in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/error.log',
|
||||
level: 'error'
|
||||
}));
|
||||
|
||||
logger.add(new winston.transports.File({
|
||||
filename: 'logs/combined.log'
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user