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,32 @@
{
"name": "user-management-service",
"version": "1.0.0",
"description": "User management service with grouping and tagging",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.5.0",
"dotenv": "^16.3.1",
"joi": "^17.10.2",
"winston": "^3.10.0",
"axios": "^1.5.0",
"cors": "^2.8.5",
"helmet": "^7.0.0",
"express-rate-limit": "^6.10.0",
"redis": "^4.6.7",
"bull": "^4.11.3",
"csv-parser": "^3.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"nodemon": "^3.0.1",
"jest": "^29.6.4",
"@types/jest": "^29.5.4"
}
}

View File

@@ -0,0 +1,65 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import mongoose from 'mongoose';
import { config } from './config/index.js';
import { logger } from './utils/logger.js';
import { errorHandler } from './middleware/errorHandler.js';
import userRoutes from './routes/userRoutes.js';
import groupRoutes from './routes/groupRoutes.js';
import tagRoutes from './routes/tagRoutes.js';
import segmentRoutes from './routes/segmentRoutes.js';
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path}`, {
query: req.query,
body: req.body,
ip: req.ip
});
next();
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'user-management',
timestamp: new Date().toISOString()
});
});
// Routes
app.use('/api/v1', userRoutes);
app.use('/api/v1', groupRoutes);
app.use('/api/v1', tagRoutes);
app.use('/api/v1', segmentRoutes);
// Error handling
app.use(errorHandler);
// Database connection
mongoose.connect(config.mongodb.uri, config.mongodb.options)
.then(() => {
logger.info('Connected to MongoDB');
})
.catch(err => {
logger.error('MongoDB connection error:', err);
process.exit(1);
});
// Start server
const PORT = config.port || 3012;
app.listen(PORT, () => {
logger.info(`User Management service listening on port ${PORT}`);
});
export default app;

View File

@@ -0,0 +1,66 @@
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: process.env.PORT || 3012,
environment: process.env.NODE_ENV || 'development',
logLevel: process.env.LOG_LEVEL || 'info',
mongodb: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/telegram_marketing',
options: {
useNewUrlParser: true,
useUnifiedTopology: true
}
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || '',
keyPrefix: 'user-mgmt:',
ttl: 3600 // 1 hour cache
},
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key-here',
expiresIn: '24h'
},
cors: {
origin: process.env.CORS_ORIGINS ?
process.env.CORS_ORIGINS.split(',') :
['http://localhost:3000', 'http://localhost:8080'],
credentials: true
},
logging: {
level: process.env.LOG_LEVEL || 'info',
format: process.env.LOG_FORMAT || 'json'
},
userManagement: {
maxGroupsPerUser: 50,
maxTagsPerUser: 100,
maxUsersPerGroup: 10000,
defaultUserStatus: 'active',
batchImportLimit: 5000,
exportBatchSize: 1000
},
segmentation: {
maxSegmentCriteria: 10,
maxSegmentSize: 100000,
cacheSegmentResults: true,
segmentCacheTTL: 300 // 5 minutes
},
import: {
supportedFormats: ['csv', 'xlsx', 'json'],
maxFileSize: 10 * 1024 * 1024, // 10MB
chunkSize: 1000,
validatePhoneNumbers: true,
deduplicateByPhone: true
}
};

View File

@@ -0,0 +1,48 @@
import jwt from 'jsonwebtoken';
import { config } from '../config/index.js';
import { logger } from '../utils/logger.js';
export const authenticateAccount = (req, res, next) => {
try {
// Get token from header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
// Verify token
const decoded = jwt.verify(token, config.jwt.secret);
// Add account info to request
req.accountId = decoded.accountId;
req.userId = decoded.userId;
req.userRole = decoded.role;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
logger.error('Authentication error:', error);
return res.status(500).json({ error: 'Authentication failed' });
}
};
export const requireRole = (roles) => {
return (req, res, next) => {
if (!roles.includes(req.userRole)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: roles,
current: req.userRole
});
}
next();
};
};

View File

@@ -0,0 +1,80 @@
import { logger } from '../utils/logger.js';
export const errorHandler = (err, req, res, next) => {
// Log error
logger.error('Error occurred:', {
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
body: req.body,
params: req.params,
query: req.query
});
// 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(400).json({
error: 'Duplicate value',
field,
message: `${field} already exists`
});
}
// Mongoose cast error
if (err.name === 'CastError') {
return res.status(400).json({
error: 'Invalid ID format',
field: err.path,
value: err.value
});
}
// 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'
});
}
// Custom business logic errors
if (err.message.includes('not found')) {
return res.status(404).json({
error: err.message
});
}
if (err.message.includes('already exists')) {
return res.status(409).json({
error: err.message
});
}
if (err.message.includes('Cannot') || err.message.includes('Invalid')) {
return res.status(400).json({
error: err.message
});
}
// 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,381 @@
import mongoose from 'mongoose';
const segmentCriteriaSchema = new mongoose.Schema({
// Multi-tenant support
tenantId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true
},
type: {
type: String,
enum: ['user_property', 'engagement', 'behavior', 'location', 'custom'],
required: true
},
field: {
type: String,
required: true
},
operator: {
type: String,
enum: ['equals', 'not_equals', 'contains', 'not_contains',
'greater_than', 'less_than', 'between', 'in', 'not_in',
'exists', 'not_exists', 'regex'],
required: true
},
value: mongoose.Schema.Types.Mixed,
dateRange: {
start: Date,
end: Date,
relative: {
value: Number,
unit: {
type: String,
enum: ['days', 'weeks', 'months', 'years']
}
}
}
});
const segmentSchema = new mongoose.Schema({
accountId: {
type: String,
required: true,
index: true
},
name: {
type: String,
required: true,
trim: true
},
description: {
type: String,
trim: true
},
criteria: [segmentCriteriaSchema],
logic: {
type: String,
enum: ['AND', 'OR', 'CUSTOM'],
default: 'AND'
},
customLogic: String, // For complex logic like (A AND B) OR (C AND D)
// Cached results
cachedCount: {
type: Number,
default: 0
},
cachedAt: Date,
cacheExpiresAt: Date,
// Performance optimization
estimatedSize: {
type: String,
enum: ['small', 'medium', 'large', 'very_large'],
default: 'medium'
},
metadata: {
createdBy: String,
lastUsedAt: Date,
usageCount: {
type: Number,
default: 0
},
tags: [String],
isTemplate: {
type: Boolean,
default: false
}
},
status: {
type: String,
enum: ['active', 'inactive', 'processing', 'error'],
default: 'active'
},
lastError: String
}, {
timestamps: true
});
// Indexes
segmentSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
segmentSchema.index({ accountId: 1, status: 1 });
segmentSchema.index({ 'metadata.usageCount': -1 });
segmentSchema.index({ cachedAt: 1 });
// Multi-tenant indexes
segmentSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
segmentSchema.index({ tenantId: 1, accountId: 1, status: 1 });
segmentSchema.index({ tenantId: 1, 'metadata.usageCount': -1 });
segmentSchema.index({ tenantId: 1, cachedAt: 1 });
// Methods
segmentSchema.methods.buildQuery = function() {
const query = { accountId: this.accountId, status: 'active' };
if (this.criteria.length === 0) {
return query;
}
const conditions = this.criteria.map(criterion => {
return this.buildCriterionQuery(criterion);
});
if (this.logic === 'OR') {
query.$or = conditions;
} else if (this.logic === 'AND') {
Object.assign(query, { $and: conditions });
} else if (this.logic === 'CUSTOM' && this.customLogic) {
// Parse custom logic - simplified implementation
// In production, use a proper expression parser
query.$and = conditions;
}
return query;
};
segmentSchema.methods.buildCriterionQuery = function(criterion) {
const condition = {};
let field = criterion.field;
// Handle nested fields
if (criterion.type === 'user_property') {
// Direct user fields
} else if (criterion.type === 'engagement') {
field = `engagement.${field}`;
} else if (criterion.type === 'location') {
field = `location.${field}`;
} else if (criterion.type === 'custom') {
field = `metadata.customFields.${field}`;
}
// Build condition based on operator
switch (criterion.operator) {
case 'equals':
condition[field] = criterion.value;
break;
case 'not_equals':
condition[field] = { $ne: criterion.value };
break;
case 'contains':
condition[field] = { $regex: criterion.value, $options: 'i' };
break;
case 'not_contains':
condition[field] = { $not: { $regex: criterion.value, $options: 'i' } };
break;
case 'greater_than':
condition[field] = { $gt: criterion.value };
break;
case 'less_than':
condition[field] = { $lt: criterion.value };
break;
case 'between':
condition[field] = {
$gte: criterion.value[0],
$lte: criterion.value[1]
};
break;
case 'in':
condition[field] = { $in: criterion.value };
break;
case 'not_in':
condition[field] = { $nin: criterion.value };
break;
case 'exists':
condition[field] = { $exists: true };
break;
case 'not_exists':
condition[field] = { $exists: false };
break;
case 'regex':
condition[field] = { $regex: criterion.value };
break;
}
// Handle date ranges
if (criterion.dateRange) {
if (criterion.dateRange.relative) {
const now = new Date();
const { value, unit } = criterion.dateRange.relative;
const date = new Date();
switch (unit) {
case 'days':
date.setDate(date.getDate() - value);
break;
case 'weeks':
date.setDate(date.getDate() - (value * 7));
break;
case 'months':
date.setMonth(date.getMonth() - value);
break;
case 'years':
date.setFullYear(date.getFullYear() - value);
break;
}
condition[field] = { $gte: date, $lte: now };
} else if (criterion.dateRange.start || criterion.dateRange.end) {
condition[field] = {};
if (criterion.dateRange.start) {
condition[field].$gte = criterion.dateRange.start;
}
if (criterion.dateRange.end) {
condition[field].$lte = criterion.dateRange.end;
}
}
}
return condition;
};
segmentSchema.methods.calculateCount = async function(useCache = true) {
// Check cache
if (useCache && this.cachedAt && this.cacheExpiresAt > new Date()) {
return this.cachedCount;
}
const User = mongoose.model('User');
const query = this.buildQuery();
const count = await User.countDocuments(query);
// Update cache
this.cachedCount = count;
this.cachedAt = new Date();
this.cacheExpiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
// Update estimated size
if (count < 100) {
this.estimatedSize = 'small';
} else if (count < 1000) {
this.estimatedSize = 'medium';
} else if (count < 10000) {
this.estimatedSize = 'large';
} else {
this.estimatedSize = 'very_large';
}
await this.save();
return count;
};
segmentSchema.methods.getUsers = async function(options = {}) {
const {
limit = 100,
skip = 0,
sort = { createdAt: -1 },
populate = 'tags groups'
} = options;
const User = mongoose.model('User');
const query = this.buildQuery();
// Update usage
this.metadata.lastUsedAt = new Date();
this.metadata.usageCount += 1;
await this.save();
return User.find(query)
.sort(sort)
.limit(limit)
.skip(skip)
.populate(populate);
};
segmentSchema.methods.testCriteria = async function(sampleSize = 10) {
try {
const users = await this.getUsers({ limit: sampleSize });
const count = await this.calculateCount(false);
return {
success: true,
sampleUsers: users,
totalCount: count,
query: this.buildQuery()
};
} catch (error) {
this.status = 'error';
this.lastError = error.message;
await this.save();
return {
success: false,
error: error.message
};
}
};
// Statics
segmentSchema.statics.createFromTemplate = async function(accountId, templateName, customizations = {}) {
const templates = {
'high_engagement': {
name: 'High Engagement Users',
description: 'Users with high engagement scores',
criteria: [{
type: 'engagement',
field: 'engagementScore',
operator: 'greater_than',
value: 70
}]
},
'recent_active': {
name: 'Recently Active',
description: 'Users active in the last 7 days',
criteria: [{
type: 'user_property',
field: 'lastActiveAt',
operator: 'greater_than',
value: null,
dateRange: {
relative: { value: 7, unit: 'days' }
}
}]
},
'at_risk': {
name: 'At Risk',
description: 'Previously engaged users becoming inactive',
criteria: [
{
type: 'engagement',
field: 'engagementScore',
operator: 'between',
value: [30, 60]
},
{
type: 'user_property',
field: 'lastActiveAt',
operator: 'less_than',
value: null,
dateRange: {
relative: { value: 14, unit: 'days' }
}
}
],
logic: 'AND'
}
};
const template = templates[templateName];
if (!template) {
throw new Error('Template not found');
}
const segment = new this({
...template,
...customizations,
accountId,
metadata: {
...template.metadata,
isTemplate: true
}
});
await segment.save();
return segment;
};
export default mongoose.model('Segment', segmentSchema);

View File

@@ -0,0 +1,222 @@
import mongoose from 'mongoose';
const tagSchema = 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,
lowercase: true
},
displayName: {
type: String,
trim: true
},
category: {
type: String,
trim: true,
default: 'general'
},
description: {
type: String,
trim: true
},
color: {
type: String,
default: '#409EFF'
},
icon: {
type: String,
default: 'tag'
},
usageCount: {
type: Number,
default: 0,
min: 0
},
metadata: {
createdBy: String,
source: {
type: String,
enum: ['manual', 'import', 'api', 'auto'],
default: 'manual'
},
isSystem: {
type: Boolean,
default: false
},
relatedTags: [String]
},
status: {
type: String,
enum: ['active', 'inactive', 'archived'],
default: 'active'
}
}, {
timestamps: true
});
// Indexes
tagSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
tagSchema.index({ accountId: 1, category: 1 });
tagSchema.index({ accountId: 1, status: 1 });
tagSchema.index({ usageCount: -1 });
// Multi-tenant indexes
tagSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
tagSchema.index({ tenantId: 1, accountId: 1, category: 1 });
tagSchema.index({ tenantId: 1, accountId: 1, status: 1 });
tagSchema.index({ tenantId: 1, usageCount: -1 });
// Virtual for display
tagSchema.virtual('display').get(function() {
return this.displayName || this.name;
});
// Methods
tagSchema.methods.getUsers = async function(limit = 100, skip = 0) {
const User = mongoose.model('User');
return User.find({
accountId: this.accountId,
tags: this._id,
status: 'active'
})
.limit(limit)
.skip(skip);
};
tagSchema.methods.merge = async function(targetTagId) {
const User = mongoose.model('User');
const targetTag = await this.constructor.findById(targetTagId);
if (!targetTag || targetTag.accountId !== this.accountId) {
throw new Error('Invalid target tag');
}
// Update all users with this tag to use the target tag
await User.updateMany(
{
accountId: this.accountId,
tags: this._id
},
{
$pull: { tags: this._id },
$addToSet: { tags: targetTagId }
}
);
// Update usage count
targetTag.usageCount += this.usageCount;
await targetTag.save();
// Archive this tag
this.status = 'archived';
this.usageCount = 0;
await this.save();
return targetTag;
};
// Statics
tagSchema.statics.findByCategory = function(accountId, category) {
return this.find({
accountId,
category,
status: 'active'
}).sort('name');
};
tagSchema.statics.getPopular = function(accountId, limit = 20) {
return this.find({
accountId,
status: 'active',
usageCount: { $gt: 0 }
})
.sort({ usageCount: -1 })
.limit(limit);
};
tagSchema.statics.getSuggestions = async function(accountId, query, limit = 10) {
const regex = new RegExp(query, 'i');
return this.find({
accountId,
status: 'active',
$or: [
{ name: regex },
{ displayName: regex }
]
})
.sort({ usageCount: -1 })
.limit(limit);
};
tagSchema.statics.createDefaultTags = async function(accountId) {
const defaultTags = [
// Customer lifecycle
{ name: 'lead', displayName: 'Lead', category: 'lifecycle', color: '#409EFF' },
{ name: 'prospect', displayName: 'Prospect', category: 'lifecycle', color: '#67C23A' },
{ name: 'customer', displayName: 'Customer', category: 'lifecycle', color: '#E6A23C' },
{ name: 'vip', displayName: 'VIP', category: 'lifecycle', color: '#F56C6C' },
// Engagement
{ name: 'engaged', displayName: 'Engaged', category: 'engagement', color: '#67C23A' },
{ name: 'inactive', displayName: 'Inactive', category: 'engagement', color: '#909399' },
{ name: 'churned', displayName: 'Churned', category: 'engagement', color: '#F56C6C' },
// Preferences
{ name: 'newsletter', displayName: 'Newsletter', category: 'preferences', color: '#409EFF' },
{ name: 'promotions', displayName: 'Promotions', category: 'preferences', color: '#E6A23C' },
{ name: 'updates', displayName: 'Updates', category: 'preferences', color: '#67C23A' },
// Source
{ name: 'organic', displayName: 'Organic', category: 'source', color: '#67C23A' },
{ name: 'paid', displayName: 'Paid', category: 'source', color: '#E6A23C' },
{ name: 'referral', displayName: 'Referral', category: 'source', color: '#409EFF' },
{ name: 'social', displayName: 'Social Media', category: 'source', color: '#909399' }
];
for (const tag of defaultTags) {
await this.findOneAndUpdate(
{ accountId, name: tag.name },
{ ...tag, accountId, metadata: { isSystem: true } },
{ upsert: true, new: true }
);
}
};
tagSchema.statics.getCategories = async function(accountId) {
const tags = await this.aggregate([
{
$match: { accountId, status: 'active' }
},
{
$group: {
_id: '$category',
count: { $sum: 1 },
totalUsage: { $sum: '$usageCount' }
}
},
{
$sort: { totalUsage: -1 }
}
]);
return tags.map(t => ({
category: t._id,
count: t.count,
usage: t.totalUsage
}));
};
export default mongoose.model('Tag', tagSchema);

View File

@@ -0,0 +1,333 @@
import mongoose from 'mongoose';
const userAttributeSchema = new mongoose.Schema({
key: {
type: String,
required: true
},
value: mongoose.Schema.Types.Mixed,
type: {
type: String,
enum: ['string', 'number', 'boolean', 'date', 'array'],
default: 'string'
}
});
const userSchema = new mongoose.Schema({
accountId: {
type: String,
required: true,
index: true
},
telegramId: {
type: String,
sparse: true,
index: true
},
phone: {
type: String,
required: true,
index: true
},
firstName: {
type: String,
trim: true
},
lastName: {
type: String,
trim: true
},
username: {
type: String,
trim: true,
sparse: true,
index: true
},
email: {
type: String,
lowercase: true,
trim: true,
sparse: true,
index: true
},
language: {
type: String,
default: 'en'
},
timezone: {
type: String,
default: 'UTC'
},
status: {
type: String,
enum: ['active', 'inactive', 'blocked', 'deleted'],
default: 'active',
index: true
},
groups: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'UserGroup'
}],
tags: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Tag'
}],
attributes: [userAttributeSchema],
preferences: {
marketingEnabled: {
type: Boolean,
default: true
},
preferredChannel: {
type: String,
enum: ['telegram', 'email', 'sms'],
default: 'telegram'
},
messageFrequency: {
type: String,
enum: ['high', 'medium', 'low'],
default: 'medium'
},
categories: [String]
},
engagement: {
lastMessageSent: Date,
lastMessageRead: Date,
totalMessagesSent: {
type: Number,
default: 0
},
totalMessagesRead: {
type: Number,
default: 0
},
clickCount: {
type: Number,
default: 0
},
conversionCount: {
type: Number,
default: 0
},
engagementScore: {
type: Number,
default: 0,
min: 0,
max: 100
}
},
metadata: {
source: {
type: String,
enum: ['import', 'api', 'manual', 'telegram', 'signup'],
default: 'manual'
},
importId: String,
customFields: {
type: Map,
of: mongoose.Schema.Types.Mixed
}
},
location: {
country: String,
city: String,
region: String,
coordinates: {
type: [Number], // [longitude, latitude]
index: '2dsphere'
}
},
lastActiveAt: Date,
blockedAt: Date,
deletedAt: Date
}, {
timestamps: true
});
// Indexes
userSchema.index({ accountId: 1, phone: 1 }, { unique: true });
userSchema.index({ accountId: 1, status: 1 });
userSchema.index({ accountId: 1, groups: 1 });
userSchema.index({ accountId: 1, tags: 1 });
userSchema.index({ 'engagement.engagementScore': -1 });
userSchema.index({ createdAt: -1 });
userSchema.index({ lastActiveAt: -1 });
// Virtual for full name
userSchema.virtual('fullName').get(function() {
const parts = [];
if (this.firstName) parts.push(this.firstName);
if (this.lastName) parts.push(this.lastName);
return parts.join(' ') || this.username || this.phone;
});
// Methods
userSchema.methods.addToGroup = async function(groupId) {
if (!this.groups.includes(groupId)) {
this.groups.push(groupId);
await this.save();
// Update group member count
const UserGroup = mongoose.model('UserGroup');
await UserGroup.findByIdAndUpdate(groupId, {
$inc: { memberCount: 1 }
});
}
};
userSchema.methods.removeFromGroup = async function(groupId) {
const index = this.groups.indexOf(groupId);
if (index > -1) {
this.groups.splice(index, 1);
await this.save();
// Update group member count
const UserGroup = mongoose.model('UserGroup');
await UserGroup.findByIdAndUpdate(groupId, {
$inc: { memberCount: -1 }
});
}
};
userSchema.methods.addTag = async function(tagId) {
if (!this.tags.includes(tagId)) {
this.tags.push(tagId);
await this.save();
// Update tag usage count
const Tag = mongoose.model('Tag');
await Tag.findByIdAndUpdate(tagId, {
$inc: { usageCount: 1 }
});
}
};
userSchema.methods.removeTag = async function(tagId) {
const index = this.tags.indexOf(tagId);
if (index > -1) {
this.tags.splice(index, 1);
await this.save();
// Update tag usage count
const Tag = mongoose.model('Tag');
await Tag.findByIdAndUpdate(tagId, {
$inc: { usageCount: -1 }
});
}
};
userSchema.methods.updateEngagement = function(type, value = 1) {
switch (type) {
case 'sent':
this.engagement.lastMessageSent = new Date();
this.engagement.totalMessagesSent += value;
break;
case 'read':
this.engagement.lastMessageRead = new Date();
this.engagement.totalMessagesRead += value;
break;
case 'click':
this.engagement.clickCount += value;
break;
case 'conversion':
this.engagement.conversionCount += value;
break;
}
// Recalculate engagement score
this.calculateEngagementScore();
};
userSchema.methods.calculateEngagementScore = function() {
const sent = this.engagement.totalMessagesSent || 1;
const read = this.engagement.totalMessagesRead || 0;
const clicks = this.engagement.clickCount || 0;
const conversions = this.engagement.conversionCount || 0;
// Weighted engagement score
const readRate = (read / sent) * 40;
const clickRate = (clicks / sent) * 30;
const conversionRate = (conversions / sent) * 30;
this.engagement.engagementScore = Math.min(100, Math.round(
readRate + clickRate + conversionRate
));
};
userSchema.methods.setAttribute = function(key, value, type = 'string') {
const existingIndex = this.attributes.findIndex(attr => attr.key === key);
if (existingIndex > -1) {
this.attributes[existingIndex].value = value;
this.attributes[existingIndex].type = type;
} else {
this.attributes.push({ key, value, type });
}
};
userSchema.methods.getAttribute = function(key) {
const attr = this.attributes.find(attr => attr.key === key);
return attr ? attr.value : null;
};
// Statics
userSchema.statics.findByGroups = function(accountId, groupIds) {
return this.find({
accountId,
groups: { $in: groupIds },
status: 'active'
});
};
userSchema.statics.findByTags = function(accountId, tagIds) {
return this.find({
accountId,
tags: { $in: tagIds },
status: 'active'
});
};
userSchema.statics.findBySegment = async function(accountId, criteria) {
const query = { accountId, status: 'active' };
// Build dynamic query based on criteria
if (criteria.groups && criteria.groups.length > 0) {
query.groups = { $in: criteria.groups };
}
if (criteria.tags && criteria.tags.length > 0) {
query.tags = { $in: criteria.tags };
}
if (criteria.engagementScore) {
query['engagement.engagementScore'] = {
$gte: criteria.engagementScore.min || 0,
$lte: criteria.engagementScore.max || 100
};
}
if (criteria.lastActive) {
const date = new Date();
date.setDate(date.getDate() - criteria.lastActive.days);
query.lastActiveAt = { $gte: date };
}
if (criteria.location) {
if (criteria.location.country) {
query['location.country'] = criteria.location.country;
}
if (criteria.location.city) {
query['location.city'] = criteria.location.city;
}
}
if (criteria.attributes) {
criteria.attributes.forEach(attr => {
query[`attributes.${attr.key}`] = attr.value;
});
}
return this.find(query);
};
export default mongoose.model('User', userSchema);

View File

@@ -0,0 +1,324 @@
import mongoose from 'mongoose';
const userGroupSchema = 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
},
type: {
type: String,
enum: ['static', 'dynamic', 'smart'],
default: 'static'
},
color: {
type: String,
default: '#409EFF'
},
icon: {
type: String,
default: 'users'
},
memberCount: {
type: Number,
default: 0,
min: 0
},
rules: {
// For dynamic/smart groups
criteria: [{
field: String,
operator: {
type: String,
enum: ['equals', 'not_equals', 'contains', 'not_contains',
'greater_than', 'less_than', 'in', 'not_in']
},
value: mongoose.Schema.Types.Mixed
}],
logic: {
type: String,
enum: ['AND', 'OR'],
default: 'AND'
},
autoUpdate: {
type: Boolean,
default: true
},
updateFrequency: {
type: String,
enum: ['realtime', 'hourly', 'daily', 'weekly'],
default: 'daily'
}
},
metadata: {
createdBy: String,
lastUpdatedBy: String,
source: {
type: String,
enum: ['manual', 'import', 'api', 'auto'],
default: 'manual'
},
tags: [String]
},
permissions: {
isPublic: {
type: Boolean,
default: false
},
allowedUsers: [String],
allowedRoles: [String]
},
status: {
type: String,
enum: ['active', 'inactive', 'archived'],
default: 'active'
},
lastCalculatedAt: Date,
nextCalculationAt: Date
}, {
timestamps: true
});
// Indexes
userGroupSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
userGroupSchema.index({ accountId: 1, status: 1 });
userGroupSchema.index({ accountId: 1, type: 1 });
userGroupSchema.index({ memberCount: -1 });
// Multi-tenant indexes
userGroupSchema.index({ tenantId: 1, accountId: 1, name: 1 }, { unique: true });
userGroupSchema.index({ tenantId: 1, accountId: 1, status: 1 });
userGroupSchema.index({ tenantId: 1, accountId: 1, type: 1 });
userGroupSchema.index({ tenantId: 1, memberCount: -1 });
// Methods
userGroupSchema.methods.calculateMembers = async function() {
if (this.type === 'static') {
// For static groups, count is maintained by add/remove operations
return this.memberCount;
}
// For dynamic groups, calculate based on rules
const User = mongoose.model('User');
const query = this.buildDynamicQuery();
const count = await User.countDocuments(query);
this.memberCount = count;
this.lastCalculatedAt = new Date();
// Set next calculation time
switch (this.rules.updateFrequency) {
case 'hourly':
this.nextCalculationAt = new Date(Date.now() + 60 * 60 * 1000);
break;
case 'daily':
this.nextCalculationAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
break;
case 'weekly':
this.nextCalculationAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
break;
}
await this.save();
return count;
};
userGroupSchema.methods.buildDynamicQuery = function() {
const query = { accountId: this.accountId, status: 'active' };
if (!this.rules.criteria || this.rules.criteria.length === 0) {
return query;
}
const conditions = this.rules.criteria.map(criterion => {
const condition = {};
const field = criterion.field;
switch (criterion.operator) {
case 'equals':
condition[field] = criterion.value;
break;
case 'not_equals':
condition[field] = { $ne: criterion.value };
break;
case 'contains':
condition[field] = { $regex: criterion.value, $options: 'i' };
break;
case 'not_contains':
condition[field] = { $not: { $regex: criterion.value, $options: 'i' } };
break;
case 'greater_than':
condition[field] = { $gt: criterion.value };
break;
case 'less_than':
condition[field] = { $lt: criterion.value };
break;
case 'in':
condition[field] = { $in: criterion.value };
break;
case 'not_in':
condition[field] = { $nin: criterion.value };
break;
}
return condition;
});
if (this.rules.logic === 'OR') {
query.$or = conditions;
} else {
Object.assign(query, ...conditions);
}
return query;
};
userGroupSchema.methods.getMembers = async function(limit = 100, skip = 0) {
const User = mongoose.model('User');
if (this.type === 'static') {
return User.find({
accountId: this.accountId,
groups: this._id,
status: 'active'
})
.limit(limit)
.skip(skip)
.populate('tags');
} else {
const query = this.buildDynamicQuery();
return User.find(query)
.limit(limit)
.skip(skip)
.populate('tags');
}
};
userGroupSchema.methods.addMembers = async function(userIds) {
if (this.type !== 'static') {
throw new Error('Cannot manually add members to dynamic groups');
}
const User = mongoose.model('User');
const result = await User.updateMany(
{
_id: { $in: userIds },
accountId: this.accountId,
groups: { $ne: this._id }
},
{
$push: { groups: this._id }
}
);
this.memberCount += result.modifiedCount;
await this.save();
return result.modifiedCount;
};
userGroupSchema.methods.removeMembers = async function(userIds) {
if (this.type !== 'static') {
throw new Error('Cannot manually remove members from dynamic groups');
}
const User = mongoose.model('User');
const result = await User.updateMany(
{
_id: { $in: userIds },
accountId: this.accountId,
groups: this._id
},
{
$pull: { groups: this._id }
}
);
this.memberCount = Math.max(0, this.memberCount - result.modifiedCount);
await this.save();
return result.modifiedCount;
};
// Statics
userGroupSchema.statics.createDefaultGroups = async function(accountId) {
const defaultGroups = [
{
name: 'All Users',
description: 'All active users',
type: 'dynamic',
icon: 'users',
color: '#409EFF',
rules: {
criteria: [],
autoUpdate: true,
updateFrequency: 'daily'
}
},
{
name: 'VIP',
description: 'VIP customers',
type: 'static',
icon: 'star',
color: '#F56C6C'
},
{
name: 'New Users',
description: 'Users joined in last 30 days',
type: 'dynamic',
icon: 'user-plus',
color: '#67C23A',
rules: {
criteria: [{
field: 'createdAt',
operator: 'greater_than',
value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
}],
autoUpdate: true,
updateFrequency: 'daily'
}
},
{
name: 'Inactive Users',
description: 'Users inactive for more than 30 days',
type: 'dynamic',
icon: 'user-clock',
color: '#909399',
rules: {
criteria: [{
field: 'lastActiveAt',
operator: 'less_than',
value: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
}],
autoUpdate: true,
updateFrequency: 'daily'
}
}
];
for (const group of defaultGroups) {
await this.findOneAndUpdate(
{ accountId, name: group.name },
{ ...group, accountId },
{ upsert: true, new: true }
);
}
};
export default mongoose.model('UserGroup', userGroupSchema);

View File

@@ -0,0 +1,159 @@
import express from 'express';
import { groupService } from '../services/groupService.js';
import { authenticateAccount } from '../middleware/auth.js';
const router = express.Router();
// Apply authentication middleware to all routes
router.use(authenticateAccount);
// Create group
router.post('/groups', async (req, res, next) => {
try {
const group = await groupService.createGroup(req.accountId, req.body);
res.json(group);
} catch (error) {
next(error);
}
});
// Get all groups
router.get('/groups', async (req, res, next) => {
try {
const result = await groupService.getGroups(req.accountId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined,
type: req.query.type,
status: req.query.status
});
res.json(result);
} catch (error) {
next(error);
}
});
// Get group by ID
router.get('/groups/:groupId', async (req, res, next) => {
try {
const group = await groupService.getGroup(req.accountId, req.params.groupId);
res.json(group);
} catch (error) {
next(error);
}
});
// Update group
router.put('/groups/:groupId', async (req, res, next) => {
try {
const group = await groupService.updateGroup(
req.accountId,
req.params.groupId,
req.body
);
res.json(group);
} catch (error) {
next(error);
}
});
// Delete group
router.delete('/groups/:groupId', async (req, res, next) => {
try {
const result = await groupService.deleteGroup(req.accountId, req.params.groupId);
res.json(result);
} catch (error) {
next(error);
}
});
// Get group members
router.get('/groups/:groupId/members', async (req, res, next) => {
try {
const result = await groupService.getGroupMembers(req.accountId, req.params.groupId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined
});
res.json(result);
} catch (error) {
next(error);
}
});
// Add members to group
router.post('/groups/:groupId/members', async (req, res, next) => {
try {
const result = await groupService.addMembers(
req.accountId,
req.params.groupId,
req.body.userIds
);
res.json(result);
} catch (error) {
next(error);
}
});
// Remove members from group
router.delete('/groups/:groupId/members', async (req, res, next) => {
try {
const result = await groupService.removeMembers(
req.accountId,
req.params.groupId,
req.body.userIds
);
res.json(result);
} catch (error) {
next(error);
}
});
// Recalculate dynamic group
router.post('/groups/:groupId/recalculate', async (req, res, next) => {
try {
const result = await groupService.recalculateGroup(
req.accountId,
req.params.groupId
);
res.json(result);
} catch (error) {
next(error);
}
});
// Test group rules
router.post('/groups/test-rules', async (req, res, next) => {
try {
const result = await groupService.testGroupRules(
req.accountId,
req.body.rules,
req.body.sampleSize
);
res.json(result);
} catch (error) {
next(error);
}
});
// Get group statistics
router.get('/groups-stats', async (req, res, next) => {
try {
const stats = await groupService.getGroupStats(req.accountId);
res.json(stats);
} catch (error) {
next(error);
}
});
// Initialize default groups
router.post('/groups/initialize-defaults', async (req, res, next) => {
try {
await groupService.initializeDefaultGroups(req.accountId);
res.json({ success: true, message: 'Default groups created' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,179 @@
import express from 'express';
import { segmentService } from '../services/segmentService.js';
import { authenticateAccount } from '../middleware/auth.js';
const router = express.Router();
// Apply authentication middleware to all routes
router.use(authenticateAccount);
// Create segment
router.post('/segments', async (req, res, next) => {
try {
const segment = await segmentService.createSegment(req.accountId, req.body);
res.json(segment);
} catch (error) {
next(error);
}
});
// Get all segments
router.get('/segments', async (req, res, next) => {
try {
const result = await segmentService.getSegments(req.accountId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined,
status: req.query.status,
estimatedSize: req.query.estimatedSize,
tags: req.query.tags ? req.query.tags.split(',') : undefined
});
res.json(result);
} catch (error) {
next(error);
}
});
// Get segment by ID
router.get('/segments/:segmentId', async (req, res, next) => {
try {
const segment = await segmentService.getSegment(req.accountId, req.params.segmentId);
res.json(segment);
} catch (error) {
next(error);
}
});
// Update segment
router.put('/segments/:segmentId', async (req, res, next) => {
try {
const segment = await segmentService.updateSegment(
req.accountId,
req.params.segmentId,
req.body
);
res.json(segment);
} catch (error) {
next(error);
}
});
// Delete segment
router.delete('/segments/:segmentId', async (req, res, next) => {
try {
const result = await segmentService.deleteSegment(
req.accountId,
req.params.segmentId
);
res.json(result);
} catch (error) {
next(error);
}
});
// Get segment users
router.get('/segments/:segmentId/users', async (req, res, next) => {
try {
const result = await segmentService.getSegmentUsers(req.accountId, req.params.segmentId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined
});
res.json(result);
} catch (error) {
next(error);
}
});
// Test segment criteria
router.post('/segments/test', async (req, res, next) => {
try {
const result = await segmentService.testSegmentCriteria(
req.accountId,
req.body.criteria,
req.body.logic,
req.body.sampleSize
);
res.json(result);
} catch (error) {
next(error);
}
});
// Calculate segment size
router.post('/segments/:segmentId/calculate', async (req, res, next) => {
try {
const result = await segmentService.calculateSegmentSize(
req.accountId,
req.params.segmentId,
req.body.forceRefresh
);
res.json(result);
} catch (error) {
next(error);
}
});
// Create segment from template
router.post('/segments/from-template', async (req, res, next) => {
try {
const segment = await segmentService.createFromTemplate(
req.accountId,
req.body.templateName,
req.body.customizations
);
res.json(segment);
} catch (error) {
next(error);
}
});
// Export segment users
router.get('/segments/:segmentId/export', async (req, res, next) => {
try {
const result = await segmentService.exportSegmentUsers(
req.accountId,
req.params.segmentId,
req.query.format || 'csv'
);
// Set appropriate headers based on format
if (result.format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${result.segment.name}-users.csv"`);
} else {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${result.segment.name}-users.json"`);
}
res.send(result.data);
} catch (error) {
next(error);
}
});
// Get segment statistics
router.get('/segments-stats', async (req, res, next) => {
try {
const stats = await segmentService.getSegmentStats(req.accountId);
res.json(stats);
} catch (error) {
next(error);
}
});
// Clone segment
router.post('/segments/:segmentId/clone', async (req, res, next) => {
try {
const segment = await segmentService.cloneSegment(
req.accountId,
req.params.segmentId,
req.body.name
);
res.json(segment);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,165 @@
import express from 'express';
import { tagService } from '../services/tagService.js';
import { authenticateAccount } from '../middleware/auth.js';
const router = express.Router();
// Apply authentication middleware to all routes
router.use(authenticateAccount);
// Create tag
router.post('/tags', async (req, res, next) => {
try {
const tag = await tagService.createTag(req.accountId, req.body);
res.json(tag);
} catch (error) {
next(error);
}
});
// Get all tags
router.get('/tags', async (req, res, next) => {
try {
const result = await tagService.getTags(req.accountId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 100,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined,
category: req.query.category,
status: req.query.status,
search: req.query.search
});
res.json(result);
} catch (error) {
next(error);
}
});
// Get tag by ID
router.get('/tags/:tagId', async (req, res, next) => {
try {
const tag = await tagService.getTag(req.accountId, req.params.tagId);
res.json(tag);
} catch (error) {
next(error);
}
});
// Update tag
router.put('/tags/:tagId', async (req, res, next) => {
try {
const tag = await tagService.updateTag(
req.accountId,
req.params.tagId,
req.body
);
res.json(tag);
} catch (error) {
next(error);
}
});
// Delete tag
router.delete('/tags/:tagId', async (req, res, next) => {
try {
const result = await tagService.deleteTag(req.accountId, req.params.tagId);
res.json(result);
} catch (error) {
next(error);
}
});
// Get tag users
router.get('/tags/:tagId/users', async (req, res, next) => {
try {
const result = await tagService.getTagUsers(req.accountId, req.params.tagId, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50
});
res.json(result);
} catch (error) {
next(error);
}
});
// Merge tags
router.post('/tags/:sourceTagId/merge', async (req, res, next) => {
try {
const result = await tagService.mergeTags(
req.accountId,
req.params.sourceTagId,
req.body.targetTagId
);
res.json(result);
} catch (error) {
next(error);
}
});
// Get tag categories
router.get('/tag-categories', async (req, res, next) => {
try {
const categories = await tagService.getCategories(req.accountId);
res.json(categories);
} catch (error) {
next(error);
}
});
// Get popular tags
router.get('/tags-popular', async (req, res, next) => {
try {
const tags = await tagService.getPopularTags(
req.accountId,
parseInt(req.query.limit) || 20
);
res.json(tags);
} catch (error) {
next(error);
}
});
// Get tag suggestions
router.get('/tags-suggestions', async (req, res, next) => {
try {
const tags = await tagService.getSuggestions(
req.accountId,
req.query.q,
parseInt(req.query.limit) || 10
);
res.json(tags);
} catch (error) {
next(error);
}
});
// Bulk create tags
router.post('/tags/bulk', async (req, res, next) => {
try {
const result = await tagService.bulkCreateTags(req.accountId, req.body.tags);
res.json(result);
} catch (error) {
next(error);
}
});
// Get tag statistics
router.get('/tags-stats', async (req, res, next) => {
try {
const stats = await tagService.getTagStats(req.accountId);
res.json(stats);
} catch (error) {
next(error);
}
});
// Initialize default tags
router.post('/tags/initialize-defaults', async (req, res, next) => {
try {
await tagService.initializeDefaultTags(req.accountId);
res.json({ success: true, message: 'Default tags created' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,153 @@
import express from 'express';
import { userService } from '../services/userService.js';
import { authenticateAccount } from '../middleware/auth.js';
const router = express.Router();
// Apply authentication middleware to all routes
router.use(authenticateAccount);
// Create or update user
router.post('/users', async (req, res, next) => {
try {
const user = await userService.upsertUser(req.accountId, req.body);
res.json(user);
} catch (error) {
next(error);
}
});
// Get user by ID or phone
router.get('/users/:identifier', async (req, res, next) => {
try {
const user = await userService.getUser(req.accountId, req.params.identifier);
res.json(user);
} catch (error) {
next(error);
}
});
// Search users
router.get('/users', async (req, res, next) => {
try {
const result = await userService.searchUsers(req.accountId, req.query, {
page: parseInt(req.query.page) || 1,
limit: parseInt(req.query.limit) || 50,
sort: req.query.sort ? JSON.parse(req.query.sort) : undefined
});
res.json(result);
} catch (error) {
next(error);
}
});
// Update user status
router.patch('/users/:userId/status', async (req, res, next) => {
try {
const user = await userService.updateUserStatus(
req.accountId,
req.params.userId,
req.body.status
);
res.json(user);
} catch (error) {
next(error);
}
});
// Bulk update users
router.patch('/users/bulk', async (req, res, next) => {
try {
const result = await userService.bulkUpdateUsers(
req.accountId,
req.body.userIds,
req.body.updates
);
res.json(result);
} catch (error) {
next(error);
}
});
// Add users to group
router.post('/users/groups/add', async (req, res, next) => {
try {
const result = await userService.addUsersToGroup(
req.accountId,
req.body.userIds,
req.body.groupId
);
res.json(result);
} catch (error) {
next(error);
}
});
// Remove users from group
router.post('/users/groups/remove', async (req, res, next) => {
try {
const result = await userService.removeUsersFromGroup(
req.accountId,
req.body.userIds,
req.body.groupId
);
res.json(result);
} catch (error) {
next(error);
}
});
// Add tags to users
router.post('/users/tags/add', async (req, res, next) => {
try {
const result = await userService.addTagsToUsers(
req.accountId,
req.body.userIds,
req.body.tagIds
);
res.json(result);
} catch (error) {
next(error);
}
});
// Remove tags from users
router.post('/users/tags/remove', async (req, res, next) => {
try {
const result = await userService.removeTagsFromUsers(
req.accountId,
req.body.userIds,
req.body.tagIds
);
res.json(result);
} catch (error) {
next(error);
}
});
// Update user engagement
router.post('/users/:userId/engagement', async (req, res, next) => {
try {
const user = await userService.updateEngagement(
req.accountId,
req.params.userId,
req.body.type,
req.body.value
);
res.json(user);
} catch (error) {
next(error);
}
});
// Get user statistics
router.get('/users-stats', async (req, res, next) => {
try {
const stats = await userService.getUserStats(req.accountId);
res.json(stats);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,326 @@
import UserGroup from '../models/UserGroup.js';
import User from '../models/User.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
export class GroupService {
/**
* Create a new group
*/
async createGroup(accountId, groupData) {
try {
const group = new UserGroup({
...groupData,
accountId
});
await group.save();
// Calculate members for dynamic groups
if (group.type !== 'static') {
await group.calculateMembers();
}
return group;
} catch (error) {
logger.error('Failed to create group:', error);
throw error;
}
}
/**
* Get group by ID
*/
async getGroup(accountId, groupId) {
const group = await UserGroup.findOne({
accountId,
_id: groupId,
status: { $ne: 'archived' }
});
if (!group) {
throw new Error('Group not found');
}
return group;
}
/**
* Get all groups
*/
async getGroups(accountId, options = {}) {
const {
page = 1,
limit = 50,
sort = { memberCount: -1 },
type,
status = 'active'
} = options;
const query = { accountId };
if (status) {
query.status = status;
}
if (type) {
query.type = type;
}
const skip = (page - 1) * limit;
const [groups, total] = await Promise.all([
UserGroup.find(query)
.sort(sort)
.skip(skip)
.limit(limit),
UserGroup.countDocuments(query)
]);
return {
groups,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update group
*/
async updateGroup(accountId, groupId, updates) {
const group = await UserGroup.findOneAndUpdate(
{
accountId,
_id: groupId,
status: { $ne: 'archived' }
},
{
...updates,
'metadata.lastUpdatedBy': updates.updatedBy
},
{ new: true }
);
if (!group) {
throw new Error('Group not found');
}
// Recalculate members if rules changed
if (group.type !== 'static' && updates.rules) {
await group.calculateMembers();
}
return group;
}
/**
* Delete group
*/
async deleteGroup(accountId, groupId) {
const group = await UserGroup.findOne({
accountId,
_id: groupId
});
if (!group) {
throw new Error('Group not found');
}
// Remove group from all users
await User.updateMany(
{
accountId,
groups: groupId
},
{
$pull: { groups: groupId }
}
);
// Archive the group
group.status = 'archived';
await group.save();
return { success: true };
}
/**
* Get group members
*/
async getGroupMembers(accountId, groupId, options = {}) {
const {
page = 1,
limit = 50,
sort = { createdAt: -1 }
} = options;
const group = await this.getGroup(accountId, groupId);
const skip = (page - 1) * limit;
const members = await group.getMembers(limit, skip);
return {
members,
group: {
id: group._id,
name: group.name,
type: group.type,
memberCount: group.memberCount
},
pagination: {
page,
limit,
total: group.memberCount,
pages: Math.ceil(group.memberCount / limit)
}
};
}
/**
* Add members to static group
*/
async addMembers(accountId, groupId, userIds) {
const group = await this.getGroup(accountId, groupId);
if (group.type !== 'static') {
throw new Error('Cannot manually add members to dynamic groups');
}
const added = await group.addMembers(userIds);
return {
added,
group
};
}
/**
* Remove members from static group
*/
async removeMembers(accountId, groupId, userIds) {
const group = await this.getGroup(accountId, groupId);
if (group.type !== 'static') {
throw new Error('Cannot manually remove members from dynamic groups');
}
const removed = await group.removeMembers(userIds);
return {
removed,
group
};
}
/**
* Recalculate dynamic group members
*/
async recalculateGroup(accountId, groupId) {
const group = await this.getGroup(accountId, groupId);
if (group.type === 'static') {
throw new Error('Cannot recalculate static groups');
}
const count = await group.calculateMembers();
return {
group,
memberCount: count
};
}
/**
* Test group rules
*/
async testGroupRules(accountId, rules, sampleSize = 10) {
const tempGroup = new UserGroup({
accountId,
name: 'Test Group',
type: 'dynamic',
rules
});
const query = tempGroup.buildDynamicQuery();
const [users, count] = await Promise.all([
User.find(query)
.limit(sampleSize)
.populate('tags'),
User.countDocuments(query)
]);
return {
sampleUsers: users,
totalCount: count,
query
};
}
/**
* Get group statistics
*/
async getGroupStats(accountId) {
const [
totalGroups,
staticGroups,
dynamicGroups,
smartGroups,
totalMembers
] = await Promise.all([
UserGroup.countDocuments({ accountId, status: 'active' }),
UserGroup.countDocuments({ accountId, status: 'active', type: 'static' }),
UserGroup.countDocuments({ accountId, status: 'active', type: 'dynamic' }),
UserGroup.countDocuments({ accountId, status: 'active', type: 'smart' }),
UserGroup.aggregate([
{ $match: { accountId, status: 'active' } },
{ $group: { _id: null, total: { $sum: '$memberCount' } } }
])
]);
const distribution = await UserGroup.aggregate([
{ $match: { accountId, status: 'active' } },
{
$bucket: {
groupBy: '$memberCount',
boundaries: [0, 10, 50, 100, 500, 1000, 10000],
default: 'large',
output: {
count: { $sum: 1 },
groups: { $push: { name: '$name', count: '$memberCount' } }
}
}
}
]);
return {
total: totalGroups,
byType: {
static: staticGroups,
dynamic: dynamicGroups,
smart: smartGroups
},
totalMembers: totalMembers[0]?.total || 0,
sizeDistribution: distribution
};
}
/**
* Initialize default groups
*/
async initializeDefaultGroups(accountId) {
try {
await UserGroup.createDefaultGroups(accountId);
logger.info(`Default groups created for account ${accountId}`);
} catch (error) {
logger.error('Failed to create default groups:', error);
throw error;
}
}
}
export const groupService = new GroupService();

View File

@@ -0,0 +1,341 @@
import Segment from '../models/Segment.js';
import User from '../models/User.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
export class SegmentService {
/**
* Create a new segment
*/
async createSegment(accountId, segmentData) {
try {
const segment = new Segment({
...segmentData,
accountId
});
await segment.save();
// Test and cache initial count
await segment.calculateCount(false);
return segment;
} catch (error) {
logger.error('Failed to create segment:', error);
throw error;
}
}
/**
* Get segment by ID
*/
async getSegment(accountId, segmentId) {
const segment = await Segment.findOne({
accountId,
_id: segmentId
});
if (!segment) {
throw new Error('Segment not found');
}
return segment;
}
/**
* Get all segments
*/
async getSegments(accountId, options = {}) {
const {
page = 1,
limit = 50,
sort = { 'metadata.usageCount': -1 },
status = 'active',
estimatedSize,
tags
} = options;
const query = { accountId };
if (status) {
query.status = status;
}
if (estimatedSize) {
query.estimatedSize = estimatedSize;
}
if (tags && tags.length > 0) {
query['metadata.tags'] = { $in: tags };
}
const skip = (page - 1) * limit;
const [segments, total] = await Promise.all([
Segment.find(query)
.sort(sort)
.skip(skip)
.limit(limit),
Segment.countDocuments(query)
]);
return {
segments,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update segment
*/
async updateSegment(accountId, segmentId, updates) {
const segment = await Segment.findOneAndUpdate(
{
accountId,
_id: segmentId
},
updates,
{ new: true }
);
if (!segment) {
throw new Error('Segment not found');
}
// Recalculate if criteria changed
if (updates.criteria || updates.logic || updates.customLogic) {
segment.status = 'processing';
await segment.save();
try {
await segment.calculateCount(false);
segment.status = 'active';
} catch (error) {
segment.status = 'error';
segment.lastError = error.message;
}
await segment.save();
}
return segment;
}
/**
* Delete segment
*/
async deleteSegment(accountId, segmentId) {
const result = await Segment.deleteOne({
accountId,
_id: segmentId
});
if (result.deletedCount === 0) {
throw new Error('Segment not found');
}
return { success: true };
}
/**
* Get segment users
*/
async getSegmentUsers(accountId, segmentId, options = {}) {
const {
page = 1,
limit = 50,
sort = { createdAt: -1 }
} = options;
const segment = await this.getSegment(accountId, segmentId);
const skip = (page - 1) * limit;
const users = await segment.getUsers({
limit,
skip,
sort
});
return {
users,
segment: {
id: segment._id,
name: segment.name,
cachedCount: segment.cachedCount
},
pagination: {
page,
limit,
total: segment.cachedCount,
pages: Math.ceil(segment.cachedCount / limit)
}
};
}
/**
* Test segment criteria
*/
async testSegmentCriteria(accountId, criteria, logic = 'AND', sampleSize = 10) {
const testSegment = new Segment({
accountId,
name: 'Test Segment',
criteria,
logic
});
return testSegment.testCriteria(sampleSize);
}
/**
* Calculate segment size
*/
async calculateSegmentSize(accountId, segmentId, forceRefresh = false) {
const segment = await this.getSegment(accountId, segmentId);
const count = await segment.calculateCount(!forceRefresh);
return {
segment: {
id: segment._id,
name: segment.name
},
count,
cachedAt: segment.cachedAt,
cacheExpiresAt: segment.cacheExpiresAt
};
}
/**
* Create segment from template
*/
async createFromTemplate(accountId, templateName, customizations = {}) {
return Segment.createFromTemplate(accountId, templateName, customizations);
}
/**
* Export segment users
*/
async exportSegmentUsers(accountId, segmentId, format = 'csv') {
const segment = await this.getSegment(accountId, segmentId);
const users = await segment.getUsers({ limit: 10000 });
let data;
if (format === 'csv') {
// Convert to CSV
const headers = ['ID', 'Phone', 'Name', 'Email', 'Status', 'Engagement Score', 'Created At'];
const rows = users.map(user => [
user._id,
user.phone,
user.fullName,
user.email || '',
user.status,
user.engagement.engagementScore,
user.createdAt.toISOString()
]);
data = [headers, ...rows].map(row => row.join(',')).join('\n');
} else {
// JSON format
data = JSON.stringify(users.map(user => ({
id: user._id,
phone: user.phone,
name: user.fullName,
email: user.email,
status: user.status,
engagementScore: user.engagement.engagementScore,
tags: user.tags.map(t => t.name),
groups: user.groups.map(g => g.name),
createdAt: user.createdAt
})), null, 2);
}
return {
data,
format,
count: users.length,
segment: {
id: segment._id,
name: segment.name
}
};
}
/**
* Get segment statistics
*/
async getSegmentStats(accountId) {
const [
totalSegments,
activeSegments,
templates,
bySize
] = await Promise.all([
Segment.countDocuments({ accountId }),
Segment.countDocuments({ accountId, status: 'active' }),
Segment.countDocuments({ accountId, 'metadata.isTemplate': true }),
Segment.aggregate([
{ $match: { accountId, status: 'active' } },
{
$group: {
_id: '$estimatedSize',
count: { $sum: 1 },
totalUsers: { $sum: '$cachedCount' }
}
}
])
]);
const recentlyUsed = await Segment.find({
accountId,
'metadata.lastUsedAt': { $exists: true }
})
.sort({ 'metadata.lastUsedAt': -1 })
.limit(5)
.select('name metadata.lastUsedAt metadata.usageCount');
return {
total: totalSegments,
active: activeSegments,
templates,
bySize: bySize.reduce((acc, item) => {
acc[item._id] = {
count: item.count,
totalUsers: item.totalUsers
};
return acc;
}, {}),
recentlyUsed
};
}
/**
* Clone segment
*/
async cloneSegment(accountId, segmentId, newName) {
const original = await this.getSegment(accountId, segmentId);
const cloned = new Segment({
accountId,
name: newName || `${original.name} (Copy)`,
description: original.description,
criteria: original.criteria,
logic: original.logic,
customLogic: original.customLogic,
metadata: {
...original.metadata,
createdBy: 'clone',
isTemplate: false
}
});
await cloned.save();
await cloned.calculateCount(false);
return cloned;
}
}
export const segmentService = new SegmentService();

View File

@@ -0,0 +1,311 @@
import Tag from '../models/Tag.js';
import User from '../models/User.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
export class TagService {
/**
* Create a new tag
*/
async createTag(accountId, tagData) {
try {
const tag = new Tag({
...tagData,
accountId
});
await tag.save();
return tag;
} catch (error) {
if (error.code === 11000) {
throw new Error('Tag name already exists');
}
logger.error('Failed to create tag:', error);
throw error;
}
}
/**
* Get tag by ID
*/
async getTag(accountId, tagId) {
const tag = await Tag.findOne({
accountId,
_id: tagId,
status: { $ne: 'archived' }
});
if (!tag) {
throw new Error('Tag not found');
}
return tag;
}
/**
* Get all tags
*/
async getTags(accountId, options = {}) {
const {
page = 1,
limit = 100,
sort = { usageCount: -1 },
category,
status = 'active',
search
} = options;
const query = { accountId };
if (status) {
query.status = status;
}
if (category) {
query.category = category;
}
if (search) {
const searchRegex = new RegExp(search, 'i');
query.$or = [
{ name: searchRegex },
{ displayName: searchRegex },
{ description: searchRegex }
];
}
const skip = (page - 1) * limit;
const [tags, total] = await Promise.all([
Tag.find(query)
.sort(sort)
.skip(skip)
.limit(limit),
Tag.countDocuments(query)
]);
return {
tags,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update tag
*/
async updateTag(accountId, tagId, updates) {
const tag = await Tag.findOneAndUpdate(
{
accountId,
_id: tagId,
status: { $ne: 'archived' }
},
updates,
{ new: true }
);
if (!tag) {
throw new Error('Tag not found');
}
return tag;
}
/**
* Delete tag
*/
async deleteTag(accountId, tagId) {
const tag = await Tag.findOne({
accountId,
_id: tagId
});
if (!tag) {
throw new Error('Tag not found');
}
if (tag.metadata.isSystem) {
throw new Error('Cannot delete system tags');
}
// Remove tag from all users
await User.updateMany(
{
accountId,
tags: tagId
},
{
$pull: { tags: tagId }
}
);
// Archive the tag
tag.status = 'archived';
tag.usageCount = 0;
await tag.save();
return { success: true };
}
/**
* Get tag users
*/
async getTagUsers(accountId, tagId, options = {}) {
const {
page = 1,
limit = 50
} = options;
const tag = await this.getTag(accountId, tagId);
const skip = (page - 1) * limit;
const users = await tag.getUsers(limit, skip);
return {
users,
tag: {
id: tag._id,
name: tag.name,
displayName: tag.displayName,
usageCount: tag.usageCount
},
pagination: {
page,
limit,
total: tag.usageCount,
pages: Math.ceil(tag.usageCount / limit)
}
};
}
/**
* Merge tags
*/
async mergeTags(accountId, sourceTagId, targetTagId) {
const sourceTag = await this.getTag(accountId, sourceTagId);
const targetTag = await this.getTag(accountId, targetTagId);
if (sourceTag.metadata.isSystem) {
throw new Error('Cannot merge system tags');
}
const result = await sourceTag.merge(targetTagId);
return {
merged: true,
targetTag: result,
usersAffected: sourceTag.usageCount
};
}
/**
* Get tag categories
*/
async getCategories(accountId) {
return Tag.getCategories(accountId);
}
/**
* Get popular tags
*/
async getPopularTags(accountId, limit = 20) {
return Tag.getPopular(accountId, limit);
}
/**
* Get tag suggestions
*/
async getSuggestions(accountId, query, limit = 10) {
return Tag.getSuggestions(accountId, query, limit);
}
/**
* Bulk create tags
*/
async bulkCreateTags(accountId, tags) {
const created = [];
const errors = [];
for (const tagData of tags) {
try {
const tag = await this.createTag(accountId, tagData);
created.push(tag);
} catch (error) {
errors.push({
tag: tagData.name,
error: error.message
});
}
}
return {
created,
errors,
success: created.length,
failed: errors.length
};
}
/**
* Get tag statistics
*/
async getTagStats(accountId) {
const [
totalTags,
activeTags,
systemTags,
categories,
topTags
] = await Promise.all([
Tag.countDocuments({ accountId }),
Tag.countDocuments({ accountId, status: 'active' }),
Tag.countDocuments({ accountId, 'metadata.isSystem': true }),
Tag.getCategories(accountId),
Tag.getPopular(accountId, 10)
]);
const usageDistribution = await Tag.aggregate([
{ $match: { accountId, status: 'active' } },
{
$bucket: {
groupBy: '$usageCount',
boundaries: [0, 1, 10, 50, 100, 500, 1000],
default: 'high',
output: {
count: { $sum: 1 },
tags: { $push: { name: '$name', count: '$usageCount' } }
}
}
}
]);
return {
total: totalTags,
active: activeTags,
system: systemTags,
categories,
topTags,
usageDistribution
};
}
/**
* Initialize default tags
*/
async initializeDefaultTags(accountId) {
try {
await Tag.createDefaultTags(accountId);
logger.info(`Default tags created for account ${accountId}`);
} catch (error) {
logger.error('Failed to create default tags:', error);
throw error;
}
}
}
export const tagService = new TagService();

View File

@@ -0,0 +1,421 @@
import User from '../models/User.js';
import UserGroup from '../models/UserGroup.js';
import Tag from '../models/Tag.js';
import { logger } from '../utils/logger.js';
import { config } from '../config/index.js';
export class UserService {
/**
* Create or update user
*/
async upsertUser(accountId, userData) {
try {
const { phone, ...otherData } = userData;
if (!phone) {
throw new Error('Phone number is required');
}
// Normalize phone number
const normalizedPhone = this.normalizePhone(phone);
const user = await User.findOneAndUpdate(
{ accountId, phone: normalizedPhone },
{
...otherData,
phone: normalizedPhone,
accountId,
lastActiveAt: new Date()
},
{
new: true,
upsert: true,
setDefaultsOnInsert: true
}
);
return user;
} catch (error) {
logger.error('Failed to upsert user:', error);
throw error;
}
}
/**
* Get user by ID or phone
*/
async getUser(accountId, identifier) {
const query = { accountId };
if (identifier.match(/^[0-9a-fA-F]{24}$/)) {
query._id = identifier;
} else {
query.phone = this.normalizePhone(identifier);
}
return User.findOne(query)
.populate('groups', 'name color icon')
.populate('tags', 'name displayName color category');
}
/**
* Search users
*/
async searchUsers(accountId, criteria, options = {}) {
const {
page = 1,
limit = 50,
sort = { createdAt: -1 }
} = options;
const query = { accountId, status: { $ne: 'deleted' } };
// Build search query
if (criteria.search) {
const searchRegex = new RegExp(criteria.search, 'i');
query.$or = [
{ phone: searchRegex },
{ firstName: searchRegex },
{ lastName: searchRegex },
{ username: searchRegex },
{ email: searchRegex }
];
}
if (criteria.status) {
query.status = criteria.status;
}
if (criteria.groups && criteria.groups.length > 0) {
query.groups = { $in: criteria.groups };
}
if (criteria.tags && criteria.tags.length > 0) {
query.tags = { $in: criteria.tags };
}
if (criteria.engagementScore) {
query['engagement.engagementScore'] = {
$gte: criteria.engagementScore.min || 0,
$lte: criteria.engagementScore.max || 100
};
}
if (criteria.dateRange) {
if (criteria.dateRange.start) {
query.createdAt = { $gte: new Date(criteria.dateRange.start) };
}
if (criteria.dateRange.end) {
query.createdAt = {
...query.createdAt,
$lte: new Date(criteria.dateRange.end)
};
}
}
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find(query)
.populate('groups', 'name color icon')
.populate('tags', 'name displayName color category')
.sort(sort)
.skip(skip)
.limit(limit),
User.countDocuments(query)
]);
return {
users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update user status
*/
async updateUserStatus(accountId, userId, status) {
const user = await User.findOneAndUpdate(
{ accountId, _id: userId },
{
status,
...(status === 'blocked' ? { blockedAt: new Date() } : {}),
...(status === 'deleted' ? { deletedAt: new Date() } : {})
},
{ new: true }
);
if (!user) {
throw new Error('User not found');
}
return user;
}
/**
* Bulk update users
*/
async bulkUpdateUsers(accountId, userIds, updates) {
const allowedUpdates = [
'status', 'language', 'timezone',
'preferences.marketingEnabled',
'preferences.preferredChannel',
'preferences.messageFrequency'
];
// Filter allowed updates
const filteredUpdates = {};
for (const [key, value] of Object.entries(updates)) {
if (allowedUpdates.includes(key)) {
filteredUpdates[key] = value;
}
}
const result = await User.updateMany(
{
accountId,
_id: { $in: userIds }
},
filteredUpdates
);
return {
matched: result.matchedCount,
modified: result.modifiedCount
};
}
/**
* Add users to group
*/
async addUsersToGroup(accountId, userIds, groupId) {
const group = await UserGroup.findOne({ accountId, _id: groupId });
if (!group) {
throw new Error('Group not found');
}
if (group.type !== 'static') {
throw new Error('Cannot manually add users to dynamic groups');
}
const result = await User.updateMany(
{
accountId,
_id: { $in: userIds },
groups: { $ne: groupId }
},
{
$push: { groups: groupId }
}
);
// Update group member count
group.memberCount += result.modifiedCount;
await group.save();
return {
added: result.modifiedCount,
group
};
}
/**
* Remove users from group
*/
async removeUsersFromGroup(accountId, userIds, groupId) {
const group = await UserGroup.findOne({ accountId, _id: groupId });
if (!group) {
throw new Error('Group not found');
}
if (group.type !== 'static') {
throw new Error('Cannot manually remove users from dynamic groups');
}
const result = await User.updateMany(
{
accountId,
_id: { $in: userIds },
groups: groupId
},
{
$pull: { groups: groupId }
}
);
// Update group member count
group.memberCount = Math.max(0, group.memberCount - result.modifiedCount);
await group.save();
return {
removed: result.modifiedCount,
group
};
}
/**
* Add tags to users
*/
async addTagsToUsers(accountId, userIds, tagIds) {
const tags = await Tag.find({
accountId,
_id: { $in: tagIds },
status: 'active'
});
if (tags.length === 0) {
throw new Error('No valid tags found');
}
const validTagIds = tags.map(t => t._id);
const result = await User.updateMany(
{
accountId,
_id: { $in: userIds }
},
{
$addToSet: { tags: { $each: validTagIds } }
}
);
// Update tag usage counts
await Tag.updateMany(
{ _id: { $in: validTagIds } },
{ $inc: { usageCount: result.modifiedCount } }
);
return {
tagged: result.modifiedCount,
tags: tags.map(t => ({ id: t._id, name: t.name }))
};
}
/**
* Remove tags from users
*/
async removeTagsFromUsers(accountId, userIds, tagIds) {
const result = await User.updateMany(
{
accountId,
_id: { $in: userIds }
},
{
$pull: { tags: { $in: tagIds } }
}
);
// Update tag usage counts
for (const tagId of tagIds) {
const count = await User.countDocuments({
accountId,
tags: tagId
});
await Tag.findByIdAndUpdate(tagId, {
usageCount: count
});
}
return {
untagged: result.modifiedCount
};
}
/**
* Update user engagement
*/
async updateEngagement(accountId, userId, type, value = 1) {
const user = await User.findOne({ accountId, _id: userId });
if (!user) {
throw new Error('User not found');
}
user.updateEngagement(type, value);
user.lastActiveAt = new Date();
await user.save();
return user;
}
/**
* Get user statistics
*/
async getUserStats(accountId) {
const [
totalUsers,
activeUsers,
inactiveUsers,
newUsersToday,
highEngagement
] = await Promise.all([
User.countDocuments({ accountId, status: { $ne: 'deleted' } }),
User.countDocuments({ accountId, status: 'active' }),
User.countDocuments({ accountId, status: 'inactive' }),
User.countDocuments({
accountId,
createdAt: { $gte: new Date(new Date().setHours(0, 0, 0, 0)) }
}),
User.countDocuments({
accountId,
'engagement.engagementScore': { $gte: 70 }
})
]);
const engagementDistribution = await User.aggregate([
{ $match: { accountId } },
{
$group: {
_id: {
$cond: [
{ $gte: ['$engagement.engagementScore', 70] },
'high',
{
$cond: [
{ $gte: ['$engagement.engagementScore', 40] },
'medium',
'low'
]
}
]
},
count: { $sum: 1 }
}
}
]);
return {
total: totalUsers,
active: activeUsers,
inactive: inactiveUsers,
newToday: newUsersToday,
highEngagement,
engagementDistribution: engagementDistribution.reduce((acc, item) => {
acc[item._id] = item.count;
return acc;
}, {})
};
}
/**
* Normalize phone number
*/
normalizePhone(phone) {
// Remove all non-digit characters
let normalized = phone.replace(/\D/g, '');
// Add + if not present
if (!normalized.startsWith('+')) {
normalized = '+' + normalized;
}
return normalized;
}
}
export const userService = new UserService();

View File

@@ -0,0 +1,26 @@
import winston from 'winston';
import { config } from '../config/index.js';
const { combine, timestamp, errors, json, simple } = winston.format;
export const logger = winston.createLogger({
level: config.logLevel || 'info',
format: combine(
errors({ stack: true }),
timestamp(),
json()
),
defaultMeta: { service: 'user-management' },
transports: [
new winston.transports.Console({
format: config.environment === 'development' ? simple() : json()
})
]
});
// Create a stream object with a 'write' function that will be used by morgan
logger.stream = {
write: (message) => {
logger.info(message.trim());
}
};