Initial commit: Telegram Management System
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:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View 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"
}
}

View File

@@ -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'
}
};

View 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);
});

View File

@@ -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' });
}
};

View File

@@ -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 })
});
}

View File

@@ -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();
};
}

View 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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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'
}));
}