Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
32
marketing-agent/services/user-management/package.json
Normal file
32
marketing-agent/services/user-management/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
marketing-agent/services/user-management/src/app.js
Normal file
65
marketing-agent/services/user-management/src/app.js
Normal 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;
|
||||
66
marketing-agent/services/user-management/src/config/index.js
Normal file
66
marketing-agent/services/user-management/src/config/index.js
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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 })
|
||||
});
|
||||
};
|
||||
381
marketing-agent/services/user-management/src/models/Segment.js
Normal file
381
marketing-agent/services/user-management/src/models/Segment.js
Normal 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);
|
||||
222
marketing-agent/services/user-management/src/models/Tag.js
Normal file
222
marketing-agent/services/user-management/src/models/Tag.js
Normal 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);
|
||||
333
marketing-agent/services/user-management/src/models/User.js
Normal file
333
marketing-agent/services/user-management/src/models/User.js
Normal 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);
|
||||
324
marketing-agent/services/user-management/src/models/UserGroup.js
Normal file
324
marketing-agent/services/user-management/src/models/UserGroup.js
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
165
marketing-agent/services/user-management/src/routes/tagRoutes.js
Normal file
165
marketing-agent/services/user-management/src/routes/tagRoutes.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
26
marketing-agent/services/user-management/src/utils/logger.js
Normal file
26
marketing-agent/services/user-management/src/utils/logger.js
Normal 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());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user